Skip to content

Commit 87712a0

Browse files
authored
feat(model-availability): introduce ModelPolicy and PolicyCatalog (#13751)
1 parent ba0e053 commit 87712a0

File tree

4 files changed

+264
-0
lines changed

4 files changed

+264
-0
lines changed

packages/core/src/availability/modelAvailabilityService.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export type UnavailabilityReason =
1414
| TurnUnavailabilityReason
1515
| 'unknown';
1616

17+
export type ModelHealthStatus = 'terminal' | 'sticky_retry';
18+
1719
type HealthState =
1820
| { status: 'terminal'; reason: TerminalUnavailabilityReason }
1921
| {
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import type { ModelHealthStatus, ModelId } from './modelAvailabilityService.js';
8+
9+
/**
10+
* Whether to prompt the user or fallback silently on a model API failure.
11+
*/
12+
export type FallbackAction = 'silent' | 'prompt';
13+
14+
/**
15+
* Type of possible errors from model API failures.
16+
*/
17+
export type FailureKind = 'terminal' | 'transient' | 'not_found' | 'unknown';
18+
19+
/**
20+
* Map from model API failure reason to user interaction.
21+
*/
22+
export type ModelPolicyActionMap = Partial<Record<FailureKind, FallbackAction>>;
23+
24+
/**
25+
* What state (e.g. Terminal, Sticky Retry) to set a model after failed API call.
26+
*/
27+
export type ModelPolicyStateMap = Partial<
28+
Record<FailureKind, ModelHealthStatus>
29+
>;
30+
31+
/**
32+
* Defines the policy for a single model in the availability chain.
33+
*
34+
* This includes:
35+
* - Which model this policy applies to.
36+
* - What actions to take (prompt vs silent fallback) for different failure kinds.
37+
* - How the model's health status should transition upon failure.
38+
* - Whether this model is considered a "last resort" (i.e. use if all models are unavailable).
39+
*/
40+
export interface ModelPolicy {
41+
model: ModelId;
42+
actions: ModelPolicyActionMap;
43+
stateTransitions: ModelPolicyStateMap;
44+
isLastResort?: boolean;
45+
}
46+
47+
/**
48+
* A chain of model policies defining the priority and fallback behavior.
49+
* The first model in the chain is the primary model.
50+
*/
51+
export type ModelPolicyChain = ModelPolicy[];
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, it, expect } from 'vitest';
8+
import {
9+
createDefaultPolicy,
10+
getModelPolicyChain,
11+
validateModelPolicyChain,
12+
} from './policyCatalog.js';
13+
import {
14+
DEFAULT_GEMINI_MODEL,
15+
PREVIEW_GEMINI_MODEL,
16+
} from '../config/models.js';
17+
18+
describe('policyCatalog', () => {
19+
it('returns preview chain when preview enabled', () => {
20+
const chain = getModelPolicyChain({ previewEnabled: true });
21+
expect(chain[0]?.model).toBe(PREVIEW_GEMINI_MODEL);
22+
expect(chain).toHaveLength(3);
23+
});
24+
25+
it('returns default chain when preview disabled', () => {
26+
const chain = getModelPolicyChain({ previewEnabled: false });
27+
expect(chain[0]?.model).toBe(DEFAULT_GEMINI_MODEL);
28+
expect(chain).toHaveLength(2);
29+
});
30+
31+
it('marks preview transients as sticky retries', () => {
32+
const [previewPolicy] = getModelPolicyChain({ previewEnabled: true });
33+
expect(previewPolicy.model).toBe(PREVIEW_GEMINI_MODEL);
34+
expect(previewPolicy.stateTransitions.transient).toBe('sticky_retry');
35+
});
36+
37+
it('applies default actions and state transitions for unspecified kinds', () => {
38+
const [previewPolicy] = getModelPolicyChain({ previewEnabled: true });
39+
expect(previewPolicy.stateTransitions.not_found).toBe('terminal');
40+
expect(previewPolicy.stateTransitions.unknown).toBe('terminal');
41+
expect(previewPolicy.actions.unknown).toBe('prompt');
42+
});
43+
44+
it('clones policy maps so edits do not leak between calls', () => {
45+
const firstCall = getModelPolicyChain({ previewEnabled: false });
46+
firstCall[0]!.actions.terminal = 'silent';
47+
const secondCall = getModelPolicyChain({ previewEnabled: false });
48+
expect(secondCall[0]!.actions.terminal).toBe('prompt');
49+
});
50+
51+
it('passes when there is exactly one last-resort policy', () => {
52+
const validChain = [
53+
createDefaultPolicy('test-model'),
54+
{ ...createDefaultPolicy('last-resort'), isLastResort: true },
55+
];
56+
expect(() => validateModelPolicyChain(validChain)).not.toThrow();
57+
});
58+
59+
it('fails when no policies are marked last-resort', () => {
60+
const chain = [
61+
createDefaultPolicy('model-a'),
62+
createDefaultPolicy('model-b'),
63+
];
64+
expect(() => validateModelPolicyChain(chain)).toThrow(
65+
'must include an `isLastResort`',
66+
);
67+
});
68+
69+
it('fails when a single-model chain is not last-resort', () => {
70+
const chain = [createDefaultPolicy('lonely-model')];
71+
expect(() => validateModelPolicyChain(chain)).toThrow(
72+
'must include an `isLastResort`',
73+
);
74+
});
75+
76+
it('fails when multiple policies are marked last-resort', () => {
77+
const chain = [
78+
{ ...createDefaultPolicy('model-a'), isLastResort: true },
79+
{ ...createDefaultPolicy('model-b'), isLastResort: true },
80+
];
81+
expect(() => validateModelPolicyChain(chain)).toThrow(
82+
'must only have one `isLastResort`',
83+
);
84+
});
85+
86+
it('createDefaultPolicy seeds default actions and states', () => {
87+
const policy = createDefaultPolicy('custom');
88+
expect(policy.actions.terminal).toBe('prompt');
89+
expect(policy.actions.unknown).toBe('prompt');
90+
expect(policy.stateTransitions.terminal).toBe('terminal');
91+
expect(policy.stateTransitions.unknown).toBe('terminal');
92+
});
93+
});
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import type {
8+
ModelPolicy,
9+
ModelPolicyActionMap,
10+
ModelPolicyChain,
11+
ModelPolicyStateMap,
12+
} from './modelPolicy.js';
13+
import {
14+
DEFAULT_GEMINI_FLASH_MODEL,
15+
DEFAULT_GEMINI_MODEL,
16+
PREVIEW_GEMINI_MODEL,
17+
} from '../config/models.js';
18+
import type { UserTierId } from '../code_assist/types.js';
19+
20+
// actions and stateTransitions are optional when defining ModelPolicy
21+
type PolicyConfig = Omit<ModelPolicy, 'actions' | 'stateTransitions'> & {
22+
actions?: ModelPolicyActionMap;
23+
stateTransitions?: ModelPolicyStateMap;
24+
};
25+
26+
export interface ModelPolicyOptions {
27+
previewEnabled: boolean;
28+
userTier?: UserTierId;
29+
}
30+
31+
const DEFAULT_ACTIONS: ModelPolicyActionMap = {
32+
terminal: 'prompt',
33+
transient: 'prompt',
34+
not_found: 'prompt',
35+
unknown: 'prompt',
36+
};
37+
38+
const DEFAULT_STATE: ModelPolicyStateMap = {
39+
terminal: 'terminal',
40+
transient: 'terminal',
41+
not_found: 'terminal',
42+
unknown: 'terminal',
43+
};
44+
45+
const DEFAULT_CHAIN: ModelPolicyChain = [
46+
definePolicy({ model: DEFAULT_GEMINI_MODEL }),
47+
definePolicy({ model: DEFAULT_GEMINI_FLASH_MODEL, isLastResort: true }),
48+
];
49+
50+
const PREVIEW_CHAIN: ModelPolicyChain = [
51+
definePolicy({
52+
model: PREVIEW_GEMINI_MODEL,
53+
stateTransitions: { transient: 'sticky_retry' },
54+
}),
55+
definePolicy({ model: DEFAULT_GEMINI_MODEL }),
56+
definePolicy({ model: DEFAULT_GEMINI_FLASH_MODEL, isLastResort: true }),
57+
];
58+
59+
/**
60+
* Returns the default ordered model policy chain for the user.
61+
*/
62+
export function getModelPolicyChain(
63+
options: ModelPolicyOptions,
64+
): ModelPolicyChain {
65+
if (options.previewEnabled) {
66+
return cloneChain(PREVIEW_CHAIN);
67+
}
68+
69+
return cloneChain(DEFAULT_CHAIN);
70+
}
71+
72+
/**
73+
* Provides a default policy scaffold for models not present in the catalog.
74+
*/
75+
export function createDefaultPolicy(model: string): ModelPolicy {
76+
return definePolicy({ model });
77+
}
78+
79+
export function validateModelPolicyChain(chain: ModelPolicyChain): void {
80+
if (chain.length === 0) {
81+
throw new Error('Model policy chain must include at least one model.');
82+
}
83+
const lastResortCount = chain.filter((policy) => policy.isLastResort).length;
84+
if (lastResortCount === 0) {
85+
throw new Error('Model policy chain must include an `isLastResort` model.');
86+
}
87+
if (lastResortCount > 1) {
88+
throw new Error('Model policy chain must only have one `isLastResort`.');
89+
}
90+
}
91+
92+
/**
93+
* Helper to define a ModelPolicy with default actions and state transitions.
94+
* Ensures every policy is a fresh instance to avoid shared state.
95+
*/
96+
function definePolicy(config: PolicyConfig): ModelPolicy {
97+
return {
98+
model: config.model,
99+
isLastResort: config.isLastResort,
100+
actions: { ...DEFAULT_ACTIONS, ...(config.actions ?? {}) },
101+
stateTransitions: {
102+
...DEFAULT_STATE,
103+
...(config.stateTransitions ?? {}),
104+
},
105+
};
106+
}
107+
108+
function clonePolicy(policy: ModelPolicy): ModelPolicy {
109+
return {
110+
...policy,
111+
actions: { ...policy.actions },
112+
stateTransitions: { ...policy.stateTransitions },
113+
};
114+
}
115+
116+
function cloneChain(chain: ModelPolicyChain): ModelPolicyChain {
117+
return chain.map(clonePolicy);
118+
}

0 commit comments

Comments
 (0)