Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions containers/api-proxy/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,66 @@ async function fetchStartupModels(adapters = []) {
modelFetchComplete = true;
}

/**
* After model fetch, validate that the requested model (if specified via
* AWF_REQUESTED_MODEL) is available in the cached model list.
* Emits a clear diagnostic log if the model is not found.
*/
function validateRequestedModel() {
const requestedModel = (process.env.AWF_REQUESTED_MODEL || '').trim();
if (!requestedModel) return;

// Collect all known models across providers
const allModels = [];
for (const models of Object.values(cachedModels)) {
if (Array.isArray(models)) allModels.push(...models);
}

if (allModels.length === 0) {
// No models fetched — cannot validate
logRequest('warn', 'model_validation_skipped', {
requested_model: requestedModel,
message: 'Cannot validate requested model — no model lists available from providers',
});
return;
}

// Check if the model or any alias of it resolves to an available model
const normalizedRequested = requestedModel.toLowerCase();
const found = allModels.some(m => m.toLowerCase() === normalizedRequested);

// Also check through model aliases — try resolution across all providers
let aliasResolved = false;
if (!found && MODEL_ALIASES) {
const { resolveModel } = require('./model-resolver');
for (const provider of Object.keys(cachedModels)) {
const result = resolveModel(requestedModel, MODEL_ALIASES.models, cachedModels, provider);
if (result) {
aliasResolved = true;
break;
}
}
}

if (!found && !aliasResolved) {
const availableModels = allModels.slice(0, 20).join(', ');
const truncated = allModels.length > 20 ? ` (and ${allModels.length - 20} more)` : '';
logRequest('error', 'model_unavailable_at_startup', {
requested_model: requestedModel,
available_count: allModels.length,
message: `Requested model '${requestedModel}' is not available in any configured provider's model list. ` +
`This typically means the model is retired, restricted, or misspelled. ` +
`Available models: ${availableModels}${truncated}`,
});
} else {
logRequest('info', 'model_validation', {
requested_model: requestedModel,
resolved_via: aliasResolved ? 'alias' : 'direct',
message: `Requested model '${requestedModel}' is available`,
});
}
}

// ── Generic provider server factory ──────────────────────────────────────────
/**
* Create a health-check request handler for a provider adapter.
Expand Down Expand Up @@ -689,6 +749,7 @@ if (require.main === module) {
});
fetchStartupModels(adaptersToStart).then(() => {
writeModelsJson();
validateRequestedModel();
}).catch((err) => {
logRequest('error', 'model_fetch_error', { message: 'Unexpected error fetching startup models', error: String(err) });
modelFetchComplete = true;
Expand Down Expand Up @@ -744,6 +805,7 @@ module.exports = {
probeProvider,
httpProbe,
fetchStartupModels,
validateRequestedModel,
// State
keyValidationResults,
resetKeyValidationState,
Expand Down
99 changes: 99 additions & 0 deletions containers/api-proxy/server.startup-model-validation.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Tests for pre-startup model validation (AWF_REQUESTED_MODEL).
*/

const { validateRequestedModel, cachedModels, resetModelCacheState } = require('./server');
const { logRequest } = require('./logging');

jest.mock('./logging', () => ({
logRequest: jest.fn(),
}));

beforeEach(() => {
jest.clearAllMocks();
resetModelCacheState();
delete process.env.AWF_REQUESTED_MODEL;
});

describe('validateRequestedModel', () => {
it('does nothing when AWF_REQUESTED_MODEL is not set', () => {
validateRequestedModel();
expect(logRequest).not.toHaveBeenCalled();
});
Comment on lines +18 to +22

it('does nothing when AWF_REQUESTED_MODEL is empty', () => {
process.env.AWF_REQUESTED_MODEL = ' ';
validateRequestedModel();
expect(logRequest).not.toHaveBeenCalled();
});

it('emits model_validation_skipped when no models are cached', () => {
process.env.AWF_REQUESTED_MODEL = 'gpt-4o';
validateRequestedModel();
expect(logRequest).toHaveBeenCalledWith('warn', 'model_validation_skipped', expect.objectContaining({
requested_model: 'gpt-4o',
}));
});

it('emits model_unavailable_at_startup when model is not found', () => {
process.env.AWF_REQUESTED_MODEL = 'gpt-5-codex';
cachedModels.copilot = ['gpt-4o', 'gpt-4o-mini', 'claude-sonnet-4-5'];
validateRequestedModel();
expect(logRequest).toHaveBeenCalledWith('error', 'model_unavailable_at_startup', expect.objectContaining({
requested_model: 'gpt-5-codex',
available_count: 3,
}));
expect(logRequest.mock.calls[0][2].message).toContain("not available");
expect(logRequest.mock.calls[0][2].message).toContain("gpt-4o");
});

it('emits model_validation success when model is found directly', () => {
process.env.AWF_REQUESTED_MODEL = 'gpt-4o';
cachedModels.copilot = ['gpt-4o', 'gpt-4o-mini'];
validateRequestedModel();
expect(logRequest).toHaveBeenCalledWith('info', 'model_validation', expect.objectContaining({
requested_model: 'gpt-4o',
resolved_via: 'direct',
}));
});

it('matches model case-insensitively', () => {
process.env.AWF_REQUESTED_MODEL = 'GPT-4o';
cachedModels.copilot = ['gpt-4o', 'gpt-4o-mini'];
validateRequestedModel();
expect(logRequest).toHaveBeenCalledWith('info', 'model_validation', expect.objectContaining({
requested_model: 'GPT-4o',
resolved_via: 'direct',
}));
});

it('searches across multiple providers', () => {
process.env.AWF_REQUESTED_MODEL = 'claude-sonnet-4-5';
cachedModels.copilot = ['gpt-4o'];
cachedModels.anthropic = ['claude-sonnet-4-5', 'claude-haiku-3'];
validateRequestedModel();
expect(logRequest).toHaveBeenCalledWith('info', 'model_validation', expect.objectContaining({
requested_model: 'claude-sonnet-4-5',
resolved_via: 'direct',
}));
});

it('skips providers with null model lists', () => {
process.env.AWF_REQUESTED_MODEL = 'gpt-4o';
cachedModels.copilot = null; // fetch failed
cachedModels.openai = ['gpt-4o', 'gpt-4o-mini'];
validateRequestedModel();
expect(logRequest).toHaveBeenCalledWith('info', 'model_validation', expect.objectContaining({
requested_model: 'gpt-4o',
}));
});

it('lists available models in the error diagnostic', () => {
process.env.AWF_REQUESTED_MODEL = 'nonexistent-model';
cachedModels.copilot = ['gpt-4o', 'gpt-4o-mini', 'o3'];
validateRequestedModel();
const message = logRequest.mock.calls[0][2].message;
expect(message).toContain('gpt-4o');
expect(message).toContain('retired, restricted, or misspelled');
});
});
33 changes: 33 additions & 0 deletions docs/awf-config-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ the corresponding CLI flag.
- `apiProxy.maxEffectiveTokens` → *(config-only; no CLI equivalent)*
- `apiProxy.modelMultipliers` → `--max-model-multiplier <model:multiplier,...>`
- `apiProxy.maxRuns` → *(config-only; no CLI equivalent)*
- `apiProxy.requestedModel` → *(config-only; maps to `AWF_REQUESTED_MODEL` for pre-startup validation)*
- `apiProxy.modelFallback` → *(config-only; model fallback strategy)*
- `apiProxy.models` → *(config-only; model alias rewriting)*
- `apiProxy.logging.debugTokens` → *(config-only; maps to `AWF_DEBUG_TOKENS`)*
Expand Down Expand Up @@ -883,6 +884,38 @@ response:
The `/reflect` endpoint does not include fallback state by design (it is static
per run).

### 12.6 Pre-Startup Model Validation

When `apiProxy.requestedModel` is configured, the API proxy validates at startup
that the specified model is available in at least one provider's model catalogue.

**Configuration:**

```json
{
"apiProxy": {
"requestedModel": "gpt-4o"
}
}
```

**Mapping:** `apiProxy.requestedModel` → `AWF_REQUESTED_MODEL` *(config-only; set by AWF CLI)*

**Behavior:**

1. After `fetchStartupModels()` completes, the proxy checks `AWF_REQUESTED_MODEL`
against all cached provider model lists.
2. If the model is found directly or resolves via model aliases, a confirmation
`model_validation` log is emitted.
3. If the model is NOT found, a `model_unavailable_at_startup` error log is
emitted listing available models as a diagnostic aid.
4. Validation is **non-blocking** — the proxy continues serving requests regardless
of the outcome, so agents that ignore the model hint are not affected.

This enables workflow authors to get clear, early feedback when a retired or
misspelled model is specified, rather than waiting for the first API request to
fail with an opaque error.

## 13. Model Alias Logging

The API proxy emits structured logging events during model alias resolution.
Expand Down
4 changes: 4 additions & 0 deletions docs/awf-config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@
"minimum": 1,
"description": "Maximum number of LLM invocations allowed for a run. When reached, the API proxy rejects subsequent requests with HTTP 429 and error type 'max_runs_exceeded'. See spec §11."
},
"requestedModel": {
"type": "string",
"description": "Expected model name for pre-startup validation. When set, the API proxy validates at startup that this model is available in at least one provider's model catalogue. Emits a clear diagnostic if the model is retired, restricted, or misspelled. Does not block startup."
},
"modelFallback": {
"type": "object",
"description": "Model fallback policy for unresolved model selections. Enabled by default as a safety net.",
Expand Down
4 changes: 4 additions & 0 deletions src/awf-config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@
"minimum": 1,
"description": "Maximum number of LLM invocations allowed for a run. When reached, the API proxy rejects subsequent requests with HTTP 429 and error type 'max_runs_exceeded'. See spec §11."
},
"requestedModel": {
"type": "string",
"description": "Expected model name for pre-startup validation. When set, the API proxy validates at startup that this model is available in at least one provider's model catalogue. Emits a clear diagnostic if the model is retired, restricted, or misspelled. Does not block startup."
},
"modelFallback": {
"type": "object",
"description": "Model fallback policy for unresolved model selections. Enabled by default as a safety net.",
Expand Down
5 changes: 5 additions & 0 deletions src/config-file-mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,11 @@ describe('mapAwfFileConfigToCliOptions', () => {
expect(result.maxRuns).toBe(42);
});

it('maps requestedModel field', () => {
const result = mapAwfFileConfigToCliOptions({ apiProxy: { requestedModel: 'gpt-4o' } });
expect(result.requestedModel).toBe('gpt-4o');
});

it('maps modelFallback field', () => {
const result = mapAwfFileConfigToCliOptions({
apiProxy: { modelFallback: { enabled: false, strategy: 'middle_power' } },
Expand Down
2 changes: 2 additions & 0 deletions src/config-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface AwfFileConfig {
maxEffectiveTokens?: number;
modelMultipliers?: Record<string, number>;
maxRuns?: number;
requestedModel?: string;
modelFallback?: {
enabled?: boolean;
strategy?: 'middle_power';
Expand Down Expand Up @@ -179,6 +180,7 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record<stri
maxEffectiveTokens: config.apiProxy?.maxEffectiveTokens,
effectiveTokenModelMultipliers: config.apiProxy?.modelMultipliers,
maxRuns: config.apiProxy?.maxRuns,
requestedModel: config.apiProxy?.requestedModel,
modelFallback: config.apiProxy?.modelFallback,
openaiApiTarget: config.apiProxy?.targets?.openai?.host,
openaiApiBasePath: config.apiProxy?.targets?.openai?.basePath,
Expand Down
5 changes: 5 additions & 0 deletions src/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,11 @@ describe('awf-config.schema.json', () => {
expect(validate({ apiProxy: { modelMultipliers: { 'gpt-4o': 0 } } })).toBe(false);
});

it('accepts apiProxy.requestedModel as a string', () => {
expect(validate({ apiProxy: { requestedModel: 'gpt-4o' } })).toBe(true);
expect(validate({ apiProxy: { requestedModel: 123 } })).toBe(false);
});

it('rejects invalid logging.logLevel values', () => {
expect(validate({ logging: { logLevel: 'verbose' } })).toBe(false);
expect(validate({ logging: { logLevel: 'debug' } })).toBe(true);
Expand Down
3 changes: 3 additions & 0 deletions src/services/api-proxy-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ function buildProviderTargetEnv(config: WrapperConfig): Record<string, string> {
if (copilotProviderBaseUrl) env.COPILOT_PROVIDER_BASE_URL = copilotProviderBaseUrl;
if (copilotProviderApiKey) env.COPILOT_PROVIDER_API_KEY = copilotProviderApiKey;

// Pre-startup model validation (non-sensitive config value)
if (config.requestedModel) env.AWF_REQUESTED_MODEL = config.requestedModel;

Comment on lines +63 to +65
return env;
}

Expand Down
15 changes: 15 additions & 0 deletions src/types/api-proxy-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,21 @@ export interface ApiProxyOptions {
*/
modelAliases?: Record<string, string[]>;

/**
* Expected model name for pre-startup validation.
*
* When set, the API proxy validates at startup that this model is available
* in at least one configured provider's model catalogue. If the model is not
* found (retired, restricted, or misspelled), a clear `model_unavailable_at_startup`
* diagnostic is emitted. This does not block proxy startup.
*
* - Config: `apiProxy.requestedModel`
* - Environment variable: `AWF_REQUESTED_MODEL` (internal; set by AWF CLI)
*
* @example 'gpt-4o'
*/
requestedModel?: string;

/**
* Enable detailed token and model-alias diagnostic logging.
*
Expand Down
Loading