diff --git a/containers/api-proxy/adapter-factory.js b/containers/api-proxy/adapter-factory.js index dd15a744d..86231dd78 100644 --- a/containers/api-proxy/adapter-factory.js +++ b/containers/api-proxy/adapter-factory.js @@ -29,7 +29,8 @@ const { * }|{ * kind: 'provider_not_configured', * message: string, - * statusCode?: number + * statusCode?: number, + * retryable?: boolean * }} spec * @returns {import('./providers/index').UnconfiguredResponse} */ @@ -37,7 +38,9 @@ function buildUnconfiguredResponse(provider, port, spec) { if (spec.kind === 'plain_error') { return { statusCode: spec.statusCode, body: { error: spec.message } }; } - const response = makeProviderNotConfiguredResponse(provider, port, spec.message); + const response = makeProviderNotConfiguredResponse(provider, port, spec.message, { + retryable: spec.retryable === true, + }); if (spec.statusCode !== undefined) { response.statusCode = spec.statusCode; } @@ -310,7 +313,8 @@ function createOidcAwareProviderAdapter({ * }|{ * kind: 'provider_not_configured', * message: string, - * statusCode?: number + * statusCode?: number, + * retryable?: boolean * }} [opts.missingCredentialResponse] - Declarative default request-time not-configured response * @param {(() => ({ * kind: 'plain_error', @@ -319,7 +323,8 @@ function createOidcAwareProviderAdapter({ * }|{ * kind: 'provider_not_configured', * message: string, - * statusCode?: number + * statusCode?: number, + * retryable?: boolean * }|null))} [opts.unconfiguredResponseWhen] - Optional override callback for request-time not-configured response * @param {(() => import('./providers/index').UnconfiguredResponse)} [opts.getUnconfiguredHealthResponse] - Optional explicit not-configured /health response (takes precedence over declarative metadata) * @param {string} [opts.healthServiceName] - Service name for auto-generated /health response (e.g. 'awf-api-proxy-gemini'); requires missingCredentialMessage diff --git a/containers/api-proxy/adapter-factory.test.js b/containers/api-proxy/adapter-factory.test.js index b9a87aee2..9b5d24b46 100644 --- a/containers/api-proxy/adapter-factory.test.js +++ b/containers/api-proxy/adapter-factory.test.js @@ -199,13 +199,14 @@ describe('buildProviderAdapter', () => { }); expect(adapter.getUnconfiguredResponse()).toEqual({ - statusCode: 503, + statusCode: 403, body: { error: { message: 'TEST_API_KEY not configured', type: 'provider_not_configured', provider: 'test', port: 10099, + retryable: false, }, }, }); diff --git a/containers/api-proxy/copilot-adapter-enterprise.test.js b/containers/api-proxy/copilot-adapter-enterprise.test.js index f29d553c4..79be4fe8a 100644 --- a/containers/api-proxy/copilot-adapter-enterprise.test.js +++ b/containers/api-proxy/copilot-adapter-enterprise.test.js @@ -318,6 +318,9 @@ describe('createCopilotAdapter — Azure OIDC (Entra) getAuthHeaders', () => { const resp = adapter.getUnconfiguredResponse(); const body = typeof resp.body === 'string' ? JSON.parse(resp.body) : resp.body; expect(body.error.message).toMatch(/OIDC token \(azure\) unavailable/); + // A pending OIDC token is genuinely transient, so this stays retryable. + expect(resp.statusCode).toBe(503); + expect(body.error.retryable).toBe(true); adapter.getOidcProvider().shutdown(); }); diff --git a/containers/api-proxy/model-config.js b/containers/api-proxy/model-config.js index 8d763cb88..767d36e34 100644 --- a/containers/api-proxy/model-config.js +++ b/containers/api-proxy/model-config.js @@ -1,6 +1,10 @@ 'use strict'; -const { parseModelAliases, filterResolvableAliases } = require('./model-resolver'); +const { + parseModelAliases, + filterResolvableAliases, + filterAvailableModelsToConfiguredProviders, +} = require('./model-resolver'); const { rewriteModelInBody } = require('./model-body-rewriter'); const { sanitizeForLog, logRequest } = require('./logging'); const { diag } = require('./token-persistence'); @@ -98,14 +102,19 @@ function getEffectiveModelFallbackForReflect(adapters) { return effectiveByProvider; } -function makeModelBodyTransform(provider, cachedModels, refreshProviderModelsForResolution) { +function makeModelBodyTransform(provider, cachedModels, refreshProviderModelsForResolution, getConfiguredModelCacheKeys) { if (!MODEL_ALIASES) return null; const providerModelFallback = getModelFallbackForProvider(provider); + const resolvableModels = () => ( + getConfiguredModelCacheKeys + ? filterAvailableModelsToConfiguredProviders(cachedModels, getConfiguredModelCacheKeys()) + : cachedModels + ); return async (body, req) => { - let result = rewriteModelInBody(body, provider, MODEL_ALIASES.models, cachedModels, providerModelFallback, MODEL_POLICY_CONFIG); + let result = rewriteModelInBody(body, provider, MODEL_ALIASES.models, resolvableModels(), providerModelFallback, MODEL_POLICY_CONFIG); if (!result || (result.fallback && result.fallback.activated)) { await refreshProviderModelsForResolution(provider); - result = rewriteModelInBody(body, provider, MODEL_ALIASES.models, cachedModels, providerModelFallback, MODEL_POLICY_CONFIG); + result = rewriteModelInBody(body, provider, MODEL_ALIASES.models, resolvableModels(), providerModelFallback, MODEL_POLICY_CONFIG); } if (!result) return null; // Store ranked candidates on the request object so endpoint-blocked retry @@ -166,6 +175,7 @@ function makeModelBodyTransform(provider, cachedModels, refreshProviderModelsFor module.exports = { MODEL_ALIASES, + filterAvailableModelsToConfiguredProviders, MODEL_FALLBACK, MODEL_POLICY_CONFIG, parseModelFallbackConfig, diff --git a/containers/api-proxy/model-discovery.js b/containers/api-proxy/model-discovery.js index 9c1d3eb22..234547724 100644 --- a/containers/api-proxy/model-discovery.js +++ b/containers/api-proxy/model-discovery.js @@ -228,7 +228,7 @@ function buildModelsJson(adapters, cachedModels, modelAliases, runtimeModelMetad for (const adapter of adapters) { const info = adapter.getReflectionInfo(); providers[adapter.name] = { - configured: adapter.isEnabled(), + configured: info.configured, models: info.models_cache_key !== null ? (cachedModels[info.models_cache_key] !== undefined ? cachedModels[info.models_cache_key] : null) : null, diff --git a/containers/api-proxy/model-resolver.js b/containers/api-proxy/model-resolver.js index 387effad3..c4d649bae 100644 --- a/containers/api-proxy/model-resolver.js +++ b/containers/api-proxy/model-resolver.js @@ -362,7 +362,8 @@ function resolveModel( * available model for at least one provider that has model data. * * An alias is kept when: - * - No provider has model data yet (unknown state — keep all aliases). + * - No provider has model data yet and its patterns can target a configured + * provider (or configured providers are unknown). * - The alias resolves to a concrete model for at least one provider with data. * * Middle-power fallback is intentionally disabled during filtering so that only @@ -371,29 +372,51 @@ function resolveModel( * * @param {Record} aliases * @param {Record} availableModels - Cached models per provider (null = not yet fetched) + * @param {Set|string[]|null|undefined} [configuredProviders] - Provider cache keys that are configured * @returns {Record} */ -function filterResolvableAliases(aliases, availableModels) { +function filterResolvableAliases(aliases, availableModels, configuredProviders) { if (!aliases || typeof aliases !== 'object') return aliases; + const configured = configuredProviders === null || configuredProviders === undefined + ? null + : (configuredProviders instanceof Set + ? configuredProviders + : new Set(Array.isArray(configuredProviders) ? configuredProviders : [])); // Providers with a non-empty model list (data is available) const providersWithData = Object.entries(availableModels) .filter(([, models]) => Array.isArray(models) && models.length > 0) .map(([provider]) => provider); - // No model data yet — cannot make decisions, keep all aliases - if (providersWithData.length === 0) return aliases; + if (providersWithData.length === 0) { + if (configured === null) return aliases; + const result = {}; + for (const aliasKey of Object.keys(aliases)) { + if (_aliasCanTargetConfiguredProvider(aliasKey, aliases, configured)) { + result[aliasKey] = aliases[aliasKey]; + } + } + return result; + } const noFallback = { enabled: false }; const result = {}; + const configuredProvidersWithoutData = configured === null + ? new Set() + : new Set([...configured].filter(provider => !Array.isArray(availableModels[provider]))); for (const aliasKey of Object.keys(aliases)) { - const canResolve = providersWithData.some(provider => { + const canResolveWithKnownModels = providersWithData.some(provider => { const resolution = resolveModel(aliasKey, aliases, availableModels, provider, [], noFallback, null, false); return resolution !== null; }); + const mayResolveWhenPendingCatalogLoads = _aliasCanTargetConfiguredProvider( + aliasKey, + aliases, + configuredProvidersWithoutData, + ); - if (canResolve) { + if (canResolveWithKnownModels || mayResolveWhenPendingCatalogLoads) { result[aliasKey] = aliases[aliasKey]; } } @@ -401,8 +424,73 @@ function filterResolvableAliases(aliases, availableModels) { return result; } +/** + * Conservatively determine whether an alias can target any configured provider + * before provider model catalogues are available. + * + * @param {string} aliasKey + * @param {Record} aliases + * @param {Set} configuredProviders + * @param {Set} [chain] + * @returns {boolean} + */ +function _aliasCanTargetConfiguredProvider(aliasKey, aliases, configuredProviders, chain = new Set()) { + const normalizedKey = aliasKey.toLowerCase(); + if (chain.has(normalizedKey)) return false; + + const aliasEntry = Object.entries(aliases).find(([key]) => key.toLowerCase() === normalizedKey); + if (!aliasEntry) return configuredProviders.size > 0; + + const nextChain = new Set(chain); + nextChain.add(normalizedKey); + const { patterns } = resolveAliasDefinition(aliasEntry[1]); + return patterns.some((pattern) => { + const slashIdx = pattern.indexOf('/'); + if (slashIdx !== -1) { + return configuredProviders.has(pattern.slice(0, slashIdx).toLowerCase()); + } + return _aliasCanTargetConfiguredProvider(pattern, aliases, configuredProviders, nextChain); + }); +} + +/** + * Restrict a provider→models map to the providers that are actually configured + * for this run. + * + * Alias resolution treats any provider with a populated model list as a valid + * steering target. When a provider slot has no credentials (the proxy reports + * `configured: false` for it and answers every request with + * `provider_not_configured`), steering a request there guarantees a 100% failure + * rate. Blanking those providers' model lists before resolution makes them + * invisible to the alias table, so candidates are only ever drawn from provider + * slots that can actually serve a request. + * + * When `configuredProviders` is null/undefined, the map is returned unchanged + * because configuration is unknown. An empty set is a known state and blanks + * every provider. + * + * @param {Record} availableModels + * @param {Set|string[]|null|undefined} configuredProviders - Provider cache keys that are configured + * @returns {Record} + */ +function filterAvailableModelsToConfiguredProviders(availableModels, configuredProviders) { + if (!availableModels || typeof availableModels !== 'object') return availableModels; + if (configuredProviders === null || configuredProviders === undefined) return availableModels; + + const configured = configuredProviders instanceof Set + ? configuredProviders + : new Set(Array.isArray(configuredProviders) ? configuredProviders : []); + + const result = {}; + for (const [provider, models] of Object.entries(availableModels)) { + result[provider] = configured.has(provider) ? models : null; + } + return result; +} + module.exports = { parseModelAliases, + filterAvailableModelsToConfiguredProviders, globMatch, extractVersionNumbers, compareByVersion, diff --git a/containers/api-proxy/model-resolver.test.js b/containers/api-proxy/model-resolver.test.js index 58ea1b210..2eadf9283 100644 --- a/containers/api-proxy/model-resolver.test.js +++ b/containers/api-proxy/model-resolver.test.js @@ -9,6 +9,7 @@ const { parseModelAliases, selectMiddlePowerFallback, filterResolvableAliases, + filterAvailableModelsToConfiguredProviders, resolveModel, } = require('./model-resolver'); const { rewriteModelInBody } = require('./model-body-rewriter'); @@ -876,3 +877,109 @@ describe('resolveModel — complex alias trees', () => { expect(result.resolvedModel).toBe('gpt-5.2'); }); }); + +// ── filterAvailableModelsToConfiguredProviders ──────────────────────────────── + +describe('filterAvailableModelsToConfiguredProviders', () => { + const availableModels = { + copilot: ['claude-sonnet-4.5', 'gpt-5.4'], + anthropic: ['claude-sonnet-5'], + }; + + it('blanks the model list of providers that are not configured', () => { + const result = filterAvailableModelsToConfiguredProviders( + availableModels, + new Set(['anthropic']), + ); + expect(result.copilot).toBeNull(); + expect(result.anthropic).toEqual(['claude-sonnet-5']); + }); + + it('accepts an array of configured provider keys', () => { + const result = filterAvailableModelsToConfiguredProviders(availableModels, ['copilot']); + expect(result.copilot).toEqual(['claude-sonnet-4.5', 'gpt-5.4']); + expect(result.anthropic).toBeNull(); + }); + + it('returns the map unchanged when the configured set is unknown', () => { + expect(filterAvailableModelsToConfiguredProviders(availableModels, null)).toBe(availableModels); + expect(filterAvailableModelsToConfiguredProviders(availableModels, undefined)).toBe(availableModels); + }); + + it('blanks every model list when no provider is configured', () => { + expect(filterAvailableModelsToConfiguredProviders(availableModels, new Set())).toEqual({ + copilot: null, + anthropic: null, + }); + }); + + it('prevents alias resolution from steering to an unconfigured provider', () => { + // Copilot-first alias group, but only Anthropic has credentials this run. + const aliases = { 'sonnet-6x': ['copilot/*sonnet*', 'anthropic/*sonnet*'] }; + const configuredOnly = filterAvailableModelsToConfiguredProviders( + availableModels, + new Set(['anthropic']), + ); + + // Copilot is unreachable: its own port must not resolve any candidate. + expect(resolveModel('sonnet-6x', aliases, configuredOnly, 'copilot', [], { enabled: false })).toBeNull(); + // Anthropic still resolves normally. + const anthropicResolution = resolveModel( + 'sonnet-6x', aliases, configuredOnly, 'anthropic', [], { enabled: false }, + ); + expect(anthropicResolution.resolvedModel).toBe('claude-sonnet-5'); + }); + + it('drops aliases that only resolve on unconfigured providers', () => { + const aliases = { 'copilot-only': ['copilot/gpt-5*'] }; + const configuredOnly = filterAvailableModelsToConfiguredProviders( + availableModels, + new Set(['anthropic']), + ); + expect(filterResolvableAliases(aliases, configuredOnly)).not.toHaveProperty('copilot-only'); + }); + + it('drops disabled-provider aliases before configured provider models are fetched', () => { + const aliases = { + 'copilot-only': ['copilot/gpt-5*'], + 'anthropic-only': ['anthropic/*sonnet*'], + default: ['anthropic-only'], + }; + const noModelData = filterAvailableModelsToConfiguredProviders( + { copilot: ['stale-model'], anthropic: null }, + new Set(['anthropic']), + ); + + expect(filterResolvableAliases(aliases, noModelData, new Set(['anthropic']))).toEqual({ + 'anthropic-only': aliases['anthropic-only'], + default: aliases.default, + }); + }); + + it('keeps aliases for configured providers whose model catalogue is still pending', () => { + const aliases = { + 'openai-model': ['openai/gpt-*'], + 'anthropic-model': ['anthropic/claude-*'], + 'copilot-model': ['copilot/gpt-*'], + }; + const configured = new Set(['openai', 'anthropic']); + const models = filterAvailableModelsToConfiguredProviders({ + openai: ['gpt-5.4'], + anthropic: null, + copilot: ['stale-model'], + }, configured); + + expect(filterResolvableAliases(aliases, models, configured)).toEqual({ + 'openai-model': aliases['openai-model'], + 'anthropic-model': aliases['anthropic-model'], + }); + }); + + it('drops all provider aliases when no provider is configured', () => { + expect(filterResolvableAliases( + { sonnet: ['copilot/*sonnet*'], default: ['sonnet'] }, + { copilot: null }, + new Set(), + )).toEqual({}); + }); +}); diff --git a/containers/api-proxy/oidc-adapter-utils.js b/containers/api-proxy/oidc-adapter-utils.js index 8f8406094..226293ec1 100644 --- a/containers/api-proxy/oidc-adapter-utils.js +++ b/containers/api-proxy/oidc-adapter-utils.js @@ -56,7 +56,10 @@ function validateAuthHeaderEnv(envVarName, rawValue, defaultHeader) { function createOidcRuntimeAdapterMethods({ staticAuthToken, oidcProvider, awsOidcProvider }) { return { isEnabled() { - return !!staticAuthToken || !!oidcProvider?.isReady() || !!awsOidcProvider?.isReady(); + if (oidcProvider || awsOidcProvider) { + return !!oidcProvider?.isReady() || !!awsOidcProvider?.isReady(); + } + return !!staticAuthToken; }, getOidcProvider() { return oidcProvider; }, getAwsOidcProvider() { return awsOidcProvider; }, diff --git a/containers/api-proxy/providers/anthropic.js b/containers/api-proxy/providers/anthropic.js index 0b8706522..9b22ac23d 100644 --- a/containers/api-proxy/providers/anthropic.js +++ b/containers/api-proxy/providers/anthropic.js @@ -156,7 +156,7 @@ function createAnthropicAdapter(env, deps = {}) { ...resolveHeaders(), 'anthropic-version': '2023-06-01', }), - reflectionConfigured: !!apiKey || oidcRequested, + reflectionConfigured: !!apiKey || oidcConfigured, reflectionExtra: () => ({ auth_type: oidcRequested ? 'github-oidc/anthropic' : 'static-key', }), @@ -176,9 +176,9 @@ function createAnthropicAdapter(env, deps = {}) { }, unconfiguredResponseWhen: () => (oidcRequested ? { - kind: 'plain_error', - statusCode: 503, + kind: 'provider_not_configured', message: oidcUnavailableError, + retryable: oidcConfigured, } : null), healthServiceName: 'awf-api-proxy-anthropic', diff --git a/containers/api-proxy/providers/copilot.js b/containers/api-proxy/providers/copilot.js index f5a555b24..ab5617213 100644 --- a/containers/api-proxy/providers/copilot.js +++ b/containers/api-proxy/providers/copilot.js @@ -217,6 +217,7 @@ function createCopilotAdapter(env, deps = {}) { ? { kind: 'provider_not_configured', message: `Copilot OIDC token (${authProvider}) unavailable; retry shortly`, + retryable: true, } : null), healthServiceName: 'awf-api-proxy-copilot', diff --git a/containers/api-proxy/providers/openai.js b/containers/api-proxy/providers/openai.js index 422026369..97dc30485 100644 --- a/containers/api-proxy/providers/openai.js +++ b/containers/api-proxy/providers/openai.js @@ -106,9 +106,9 @@ function createOpenAIAdapter(env, deps = {}) { }, unconfiguredResponseWhen: () => (oidcConfigured ? { - kind: 'plain_error', - statusCode: 503, + kind: 'provider_not_configured', message: 'OpenAI OIDC token unavailable; retry shortly', + retryable: true, } : null), extra: { diff --git a/containers/api-proxy/proxy-utils.js b/containers/api-proxy/proxy-utils.js index 3b22bc776..c2408879e 100644 --- a/containers/api-proxy/proxy-utils.js +++ b/containers/api-proxy/proxy-utils.js @@ -241,20 +241,31 @@ function composeBodyTransforms(first, second) { /** * Build a standard provider-not-configured proxy response payload. * + * Missing credentials are a **terminal** run-level misconfiguration: the slot + * cannot become configured mid-run, so every retry is guaranteed to fail again. + * A 503 invites LLM SDK clients to retry with backoff, which has produced + * multi-minute non-terminating retry loops. Such responses therefore use HTTP + * `403` and carry `retryable: false` so clients fast-fail. Genuinely transient + * states (e.g. an OIDC token that is not minted yet) pass `retryable: true` and + * keep the retry-friendly `503`. + * * @param {string} provider * @param {number} port * @param {string} message - * @returns {{ statusCode: number, body: { error: { message: string, type: string, provider: string, port: number } } }} + * @param {{ retryable?: boolean }} [opts] + * @returns {{ statusCode: number, body: { error: { message: string, type: string, provider: string, port: number, retryable: boolean } } }} */ -function makeProviderNotConfiguredResponse(provider, port, message) { +function makeProviderNotConfiguredResponse(provider, port, message, opts = {}) { + const retryable = opts.retryable === true; return { - statusCode: 503, + statusCode: retryable ? 503 : 403, body: { error: { message, type: 'provider_not_configured', provider, port, + retryable, }, }, }; diff --git a/containers/api-proxy/proxy-utils.oidc.test.js b/containers/api-proxy/proxy-utils.oidc.test.js index 705d58e1a..e4ccc0fd8 100644 --- a/containers/api-proxy/proxy-utils.oidc.test.js +++ b/containers/api-proxy/proxy-utils.oidc.test.js @@ -54,6 +54,16 @@ describe('createOidcRuntimeAdapterMethods', () => { expect(methods.getOidcProvider()).toEqual({ isReady: expect.any(Function) }); expect(methods.getAwsOidcProvider()).toEqual({ isReady: expect.any(Function) }); }); + + it('is disabled while selected OIDC auth is pending even when a static key exists', () => { + const methods = createOidcRuntimeAdapterMethods({ + staticAuthToken: 'static-token', + oidcProvider: { isReady: () => false }, + awsOidcProvider: null, + }); + + expect(methods.isEnabled()).toBe(false); + }); }); describe('resolveOidcAuthHeaders', () => { diff --git a/containers/api-proxy/server.auth-matrix.test.js b/containers/api-proxy/server.auth-matrix.test.js index 358d78d8c..c47098fe6 100644 --- a/containers/api-proxy/server.auth-matrix.test.js +++ b/containers/api-proxy/server.auth-matrix.test.js @@ -620,6 +620,18 @@ describe('Auth Matrix — isEnabled with OIDC', () => { AWF_AUTH_AZURE_CLIENT_ID: 'client', }); expect(adapter.isEnabled()).toBe(false); + expect(adapter.getUnconfiguredResponse()).toEqual({ + statusCode: 503, + body: { + error: { + message: 'OpenAI OIDC token unavailable; retry shortly', + type: 'provider_not_configured', + provider: 'openai', + port: 10000, + retryable: true, + }, + }, + }); adapter.getOidcProvider().shutdown(); }); diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index 023362f13..a4201fadd 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -19,6 +19,7 @@ const { parseModelFallbackConfig, makeModelBodyTransform: makeModelBodyTransformForProvider, filterResolvableAliases, + filterAvailableModelsToConfiguredProviders, getEffectiveModelFallbackForReflect, } = require('./model-config'); const { @@ -98,8 +99,29 @@ if (!HTTPS_PROXY) { const { createAllAdapters } = require('./providers'); +/** + * Model cache keys of the provider slots that are actually configured for this + * run. Alias resolution must never steer a request to a provider that reports + * `configured: false` — every such call fails with `provider_not_configured`. + */ +function getConfiguredModelCacheKeys(adapters = registeredAdapters) { + const keys = new Set(); + for (const adapter of adapters) { + const reflection = adapter.getReflectionInfo(); + if (!reflection.configured) continue; + const cacheKey = reflection.models_cache_key; + if (cacheKey) keys.add(cacheKey); + } + return keys; +} + function makeModelBodyTransform(provider) { - return makeModelBodyTransformForProvider(provider, cachedModels, refreshProviderModelsForResolution); + return makeModelBodyTransformForProvider( + provider, + cachedModels, + refreshProviderModelsForResolution, + getConfiguredModelCacheKeys, + ); } const registeredAdapters = createAllAdapters(process.env, { @@ -124,7 +146,14 @@ const { healthResponse, reflectEndpoints, handleManagementEndpoint } = createMan httpsProxy: HTTPS_PROXY, getModelAliases: () => { if (!MODEL_ALIASES) return null; - return { models: filterResolvableAliases(MODEL_ALIASES.models, cachedModels) }; + const configuredProviders = getConfiguredModelCacheKeys(); + return { + models: filterResolvableAliases( + MODEL_ALIASES.models, + filterAvailableModelsToConfiguredProviders(cachedModels, configuredProviders), + configuredProviders, + ), + }; }, getModelFallback: () => MODEL_FALLBACK, getEffectiveModelFallback: () => getEffectiveModelFallbackForReflect(registeredAdapters), @@ -136,16 +165,26 @@ const { healthResponse, reflectEndpoints, handleManagementEndpoint } = createMan }); function buildModelsJson() { - const filteredAliases = MODEL_ALIASES - ? { models: filterResolvableAliases(MODEL_ALIASES.models, cachedModels) } - : null; + const configuredProviders = getConfiguredModelCacheKeys(); + const filteredAliases = MODEL_ALIASES ? { + models: filterResolvableAliases( + MODEL_ALIASES.models, + filterAvailableModelsToConfiguredProviders(cachedModels, configuredProviders), + configuredProviders, + ), + } : null; return _buildModelsJson(registeredAdapters, cachedModels, filteredAliases, getRuntimeCatalogSnapshot()); } function writeModelsJson(logDir) { - const filteredAliases = MODEL_ALIASES - ? { models: filterResolvableAliases(MODEL_ALIASES.models, cachedModels) } - : null; + const configuredProviders = getConfiguredModelCacheKeys(); + const filteredAliases = MODEL_ALIASES ? { + models: filterResolvableAliases( + MODEL_ALIASES.models, + filterAvailableModelsToConfiguredProviders(cachedModels, configuredProviders), + configuredProviders, + ), + } : null; const modelsJson = _buildModelsJson( registeredAdapters, cachedModels, @@ -207,6 +246,7 @@ module.exports = { healthResponse, buildModelsJson, writeModelsJson, + getConfiguredModelCacheKeys, extractBillingHeaders, createProviderServer, }; diff --git a/containers/api-proxy/server.lifecycle.test.js b/containers/api-proxy/server.lifecycle.test.js index 859e235e3..2180f4500 100644 --- a/containers/api-proxy/server.lifecycle.test.js +++ b/containers/api-proxy/server.lifecycle.test.js @@ -551,10 +551,11 @@ describe('provider adapter alwaysBind', () => { expect(adapter.alwaysBind).toBe(true); }); - it('anthropic getUnconfiguredResponse returns 503 with structured error', () => { + it('anthropic getUnconfiguredResponse returns a non-retryable 403 with structured error', () => { const adapter = createAnthropicAdapter({}); const { statusCode, body } = adapter.getUnconfiguredResponse(); - expect(statusCode).toBe(503); + expect(statusCode).toBe(403); + expect(body.error.retryable).toBe(false); expect(body.error.type).toBe('provider_not_configured'); expect(body.error.provider).toBe('anthropic'); expect(body.error.port).toBe(10001); @@ -589,7 +590,15 @@ describe('provider adapter alwaysBind', () => { expect(adapter.getReflectionInfo().auth_type).toBe('github-oidc/anthropic'); expect(adapter.getUnconfiguredResponse()).toEqual({ statusCode: 503, - body: { error: 'Anthropic OIDC token unavailable; retry shortly' }, + body: { + error: { + message: 'Anthropic OIDC token unavailable; retry shortly', + type: 'provider_not_configured', + provider: 'anthropic', + port: 10001, + retryable: true, + }, + }, }); expect(adapter.getUnconfiguredHealthResponse()).toEqual({ statusCode: 503, @@ -611,14 +620,20 @@ describe('provider adapter alwaysBind', () => { expect(adapter.getOidcProvider()).toBeNull(); expect(adapter.isEnabled()).toBe(false); - expect(adapter.getReflectionInfo().configured).toBe(true); + expect(adapter.getReflectionInfo().configured).toBe(false); expect(adapter.getReflectionInfo().auth_type).toBe('github-oidc/anthropic'); expect(adapter.getValidationProbe()).toBeNull(); expect(adapter.getModelsFetchConfig()).toBeNull(); expect(adapter.getUnconfiguredResponse()).toEqual({ - statusCode: 503, + statusCode: 403, body: { - error: 'Anthropic OIDC requires ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN (permissions: id-token: write).', + error: { + message: 'Anthropic OIDC requires ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN (permissions: id-token: write).', + type: 'provider_not_configured', + provider: 'anthropic', + port: 10001, + retryable: false, + }, }, }); expect(adapter.getUnconfiguredHealthResponse()).toEqual({ @@ -631,10 +646,11 @@ describe('provider adapter alwaysBind', () => { }); }); - it('copilot getUnconfiguredResponse returns 503 with structured error', () => { + it('copilot getUnconfiguredResponse returns a non-retryable 403 with structured error', () => { const adapter = createCopilotAdapter({}); const { statusCode, body } = adapter.getUnconfiguredResponse(); - expect(statusCode).toBe(503); + expect(statusCode).toBe(403); + expect(body.error.retryable).toBe(false); expect(body.error.type).toBe('provider_not_configured'); expect(body.error.provider).toBe('copilot'); expect(body.error.port).toBe(10002); diff --git a/containers/api-proxy/server.models.test.js b/containers/api-proxy/server.models.test.js index 2e473cc29..7e59e1dc2 100644 --- a/containers/api-proxy/server.models.test.js +++ b/containers/api-proxy/server.models.test.js @@ -4,7 +4,16 @@ * Extracted from server.test.js during test-file refactoring. */ -const { cachedModels, resetModelCacheState, makeModelBodyTransform, MODEL_ALIASES, buildModelsJson, writeModelsJson } = require('./server'); +const { + cachedModels, + resetModelCacheState, + makeModelBodyTransform, + MODEL_ALIASES, + buildModelsJson, + writeModelsJson, + getConfiguredModelCacheKeys, +} = require('./server'); +const { buildModelsJson: buildModelsPayload } = require('./model-discovery'); const { composeBodyTransforms } = require('./proxy-utils'); describe('makeModelBodyTransform', () => { @@ -170,10 +179,12 @@ describe('makeModelBodyTransform', () => { const prevDebugTokens = process.env.AWF_DEBUG_TOKENS; const prevLogDir = process.env.AWF_TOKEN_LOG_DIR; const prevAliases = process.env.AWF_MODEL_ALIASES; + const prevCopilotToken = process.env.COPILOT_GITHUB_TOKEN; process.env.AWF_DEBUG_TOKENS = '1'; process.env.AWF_TOKEN_LOG_DIR = tmpDir; process.env.AWF_MODEL_ALIASES = JSON.stringify({ models: { sonnet: ['copilot/*sonnet*'] } }); + process.env.COPILOT_GITHUB_TOKEN = 'test-token'; let isolatedServer; let tokenPersistence; @@ -221,6 +232,8 @@ describe('makeModelBodyTransform', () => { if (prevAliases === undefined) delete process.env.AWF_MODEL_ALIASES; else process.env.AWF_MODEL_ALIASES = prevAliases; + if (prevCopilotToken === undefined) delete process.env.COPILOT_GITHUB_TOKEN; + else process.env.COPILOT_GITHUB_TOKEN = prevCopilotToken; try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } } @@ -229,8 +242,10 @@ describe('makeModelBodyTransform', () => { it('emits model_fallback_activated and model_fallback_candidates logs when middle fallback is used', async () => { const prevAliases = process.env.AWF_MODEL_ALIASES; const prevFallback = process.env.AWF_MODEL_FALLBACK; + const prevOpenAiKey = process.env.OPENAI_API_KEY; process.env.AWF_MODEL_ALIASES = JSON.stringify({ models: { sonnet: ['openai/*sonnet*'] } }); process.env.AWF_MODEL_FALLBACK = JSON.stringify({ enabled: true, strategy: 'middle_power' }); + process.env.OPENAI_API_KEY = 'test-key'; const stdoutSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); @@ -261,14 +276,18 @@ describe('makeModelBodyTransform', () => { else process.env.AWF_MODEL_ALIASES = prevAliases; if (prevFallback === undefined) delete process.env.AWF_MODEL_FALLBACK; else process.env.AWF_MODEL_FALLBACK = prevFallback; + if (prevOpenAiKey === undefined) delete process.env.OPENAI_API_KEY; + else process.env.OPENAI_API_KEY = prevOpenAiKey; } }); it('emits model_fallback_skipped log when normal resolution succeeds', async () => { const prevAliases = process.env.AWF_MODEL_ALIASES; const prevFallback = process.env.AWF_MODEL_FALLBACK; + const prevOpenAiKey = process.env.OPENAI_API_KEY; process.env.AWF_MODEL_ALIASES = JSON.stringify({ models: { sonnet: ['openai/*sonnet*'] } }); process.env.AWF_MODEL_FALLBACK = JSON.stringify({ enabled: true, strategy: 'middle_power' }); + process.env.OPENAI_API_KEY = 'test-key'; const stdoutSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); @@ -298,6 +317,8 @@ describe('makeModelBodyTransform', () => { else process.env.AWF_MODEL_ALIASES = prevAliases; if (prevFallback === undefined) delete process.env.AWF_MODEL_FALLBACK; else process.env.AWF_MODEL_FALLBACK = prevFallback; + if (prevOpenAiKey === undefined) delete process.env.OPENAI_API_KEY; + else process.env.OPENAI_API_KEY = prevOpenAiKey; } }); @@ -410,6 +431,7 @@ describe('buildModelsJson', () => { // This test requires an isolated module with specific AWF_MODEL_ALIASES config. const prevAliases = process.env.AWF_MODEL_ALIASES; const prevFallback = process.env.AWF_MODEL_FALLBACK; + const prevCopilotToken = process.env.COPILOT_GITHUB_TOKEN; process.env.AWF_MODEL_ALIASES = JSON.stringify({ models: { sonnet: ['copilot/*sonnet*'], @@ -417,6 +439,7 @@ describe('buildModelsJson', () => { }, }); process.env.AWF_MODEL_FALLBACK = JSON.stringify({ enabled: false }); + process.env.COPILOT_GITHUB_TOKEN = 'test-token'; try { let isolatedServer; @@ -436,17 +459,21 @@ describe('buildModelsJson', () => { else process.env.AWF_MODEL_ALIASES = prevAliases; if (prevFallback === undefined) delete process.env.AWF_MODEL_FALLBACK; else process.env.AWF_MODEL_FALLBACK = prevFallback; + if (prevCopilotToken === undefined) delete process.env.COPILOT_GITHUB_TOKEN; + else process.env.COPILOT_GITHUB_TOKEN = prevCopilotToken; } }); it('should keep all model_aliases when no provider has model data yet', () => { const prevAliases = process.env.AWF_MODEL_ALIASES; + const prevCopilotToken = process.env.COPILOT_GITHUB_TOKEN; process.env.AWF_MODEL_ALIASES = JSON.stringify({ models: { sonnet: ['copilot/*sonnet*'], 'no-match': ['copilot/nonexistent-model'], }, }); + process.env.COPILOT_GITHUB_TOKEN = 'test-token'; try { let isolatedServer; @@ -462,9 +489,71 @@ describe('buildModelsJson', () => { } finally { if (prevAliases === undefined) delete process.env.AWF_MODEL_ALIASES; else process.env.AWF_MODEL_ALIASES = prevAliases; + if (prevCopilotToken === undefined) delete process.env.COPILOT_GITHUB_TOKEN; + else process.env.COPILOT_GITHUB_TOKEN = prevCopilotToken; } }); + it('filters aliases by configured reflection slots before model data is available', () => { + const previous = { + aliases: process.env.AWF_MODEL_ALIASES, + anthropicKey: process.env.ANTHROPIC_API_KEY, + }; + process.env.AWF_MODEL_ALIASES = JSON.stringify({ + models: { + 'copilot-only': ['copilot/*sonnet*'], + 'anthropic-only': ['anthropic/*sonnet*'], + }, + }); + process.env.ANTHROPIC_API_KEY = 'test-key'; + + try { + let isolatedServer; + jest.isolateModules(() => { isolatedServer = require('./server'); }); + isolatedServer.resetModelCacheState(); + + expect(isolatedServer.buildModelsJson().model_aliases).toEqual({ + 'anthropic-only': ['anthropic/*sonnet*'], + }); + } finally { + if (previous.aliases === undefined) delete process.env.AWF_MODEL_ALIASES; + else process.env.AWF_MODEL_ALIASES = previous.aliases; + if (previous.anthropicKey === undefined) delete process.env.ANTHROPIC_API_KEY; + else process.env.ANTHROPIC_API_KEY = previous.anthropicKey; + } + }); + +}); + +describe('getConfiguredModelCacheKeys', () => { + it('uses reflected configuration rather than transient adapter readiness', () => { + const pendingOidcAdapter = { + isEnabled: () => false, + getReflectionInfo: () => ({ + configured: true, + models_cache_key: 'copilot', + }), + }; + + expect(getConfiguredModelCacheKeys([pendingOidcAdapter])).toEqual(new Set(['copilot'])); + }); +}); + +describe('model discovery configuration', () => { + it('reports reflected OIDC configuration while the token is still pending', () => { + const pendingOidcAdapter = { + name: 'copilot', + isEnabled: () => false, + getTargetHost: () => 'api.githubcopilot.com', + getReflectionInfo: () => ({ + configured: true, + models_cache_key: 'copilot', + }), + }; + + expect(buildModelsPayload([pendingOidcAdapter], { copilot: null }, null).providers.copilot) + .toMatchObject({ configured: true, models: null }); + }); }); // ── writeModelsJson ──────────────────────────────────────────────────────── diff --git a/containers/api-proxy/server.routing.test.js b/containers/api-proxy/server.routing.test.js index b6e221b64..0922ca884 100644 --- a/containers/api-proxy/server.routing.test.js +++ b/containers/api-proxy/server.routing.test.js @@ -168,15 +168,31 @@ describe('createAdapterMethods', () => { }); describe('makeProviderNotConfiguredResponse', () => { - it('builds a standard provider_not_configured 503 payload', () => { + it('builds a non-retryable provider_not_configured 403 payload by default', () => { expect(makeProviderNotConfiguredResponse('anthropic', 10001, 'missing key')).toEqual({ - statusCode: 503, + statusCode: 403, body: { error: { message: 'missing key', type: 'provider_not_configured', provider: 'anthropic', port: 10001, + retryable: false, + }, + }, + }); + }); + + it('builds a retryable provider_not_configured 503 payload when opted in', () => { + expect(makeProviderNotConfiguredResponse('copilot', 10002, 'token not ready', { retryable: true })).toEqual({ + statusCode: 503, + body: { + error: { + message: 'token not ready', + type: 'provider_not_configured', + provider: 'copilot', + port: 10002, + retryable: true, }, }, }); diff --git a/docs/api-proxy-sidecar.md b/docs/api-proxy-sidecar.md index 379b73007..e71d60788 100644 --- a/docs/api-proxy-sidecar.md +++ b/docs/api-proxy-sidecar.md @@ -955,6 +955,8 @@ apiProxy: When disabled (the default), thresholds are still tracked and exposed via `/reflect`, but no warning messages are injected into request bodies. +To opt a workflow out explicitly, set `apiProxy.enableTokenSteering: false` (or omit the field). The CLI/config value is the only source of the sidecar's `AWF_ENABLE_TOKEN_STEERING` env var, which is emitted only when steering is enabled. + #### How steering messages are injected When a threshold is crossed, the proxy modifies the outgoing request body of the *next* API call to include a system-level warning. This ensures the agent receives budget information even if it doesn't parse headers or error responses. The message format is: diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index 71fa140e2..ea7fb1749 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -106,7 +106,7 @@ AWF settings MAY be supplied via config files, including stdin (`--config -`). - `network.isolation` → `--network-isolation` *(experimental; enforces egress via Docker network topology instead of host iptables)* - `network.topologyAttach[]` → `--topology-attach ` *(repeatable; requires `network.isolation: true`)* - `apiProxy.enabled` → `--enable-api-proxy` *([DEPRECATED] API proxy is always enabled; this flag is ignored)* -- `apiProxy.enableTokenSteering` → `--enable-token-steering` +- `apiProxy.enableTokenSteering` → `--enable-token-steering` *(maps to `AWF_ENABLE_TOKEN_STEERING`; omit or set to `false` to opt out)* - `apiProxy.anthropicAutoCache` → `--anthropic-auto-cache` - `apiProxy.anthropicCacheTailTtl` → `--anthropic-cache-tail-ttl <5m|1h>` - `apiProxy.maxEffectiveTokens` → *(config-only; no CLI equivalent)* @@ -702,8 +702,11 @@ Each threshold MUST be recorded at most once per run. ### 10.5 Token Steering Token steering is **opt-in**. It is active only when `apiProxy.enableTokenSteering` -is `true` (CLI: `--enable-token-steering`). When disabled (the default), thresholds -are still tracked (for introspection) but no warning messages are injected. +is `true` (CLI: `--enable-token-steering`), which sets `AWF_ENABLE_TOKEN_STEERING=true` +in the api-proxy sidecar. When disabled (the default), thresholds are still tracked +(for introspection) but no warning messages are injected. Setting the field to +`false`, or omitting it, opts a workflow out; the env var is only emitted when the +value is `true`. When token steering is enabled and a threshold is first crossed, the proxy MUST inject a budget-warning system message into the **body** of the very next eligible @@ -1346,6 +1349,31 @@ 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. +### 12.1 Alias Candidates Are Restricted to Configured Providers + +Alias resolution MUST only consider provider slots that are actually configured +for the run. Before an alias is expanded (and before aliases are advertised via +`/reflect` and `models.json`), the cached model lists of providers that report +`configured: false` are treated as empty. A provider-scoped pattern such as +`copilot/*sonnet*` therefore yields no candidate when no Copilot credential is +present, even if a model list was cached earlier in the run. + +Configuration is determined from each provider's reflected `configured` slot, +not from request readiness. A configured OIDC provider remains eligible while +its token is being minted. When configured providers have no model catalogue +yet, aliases scoped only to unconfigured providers are still omitted, while +aliases that can target a configured provider remain advertised until model +data is available. + +Without this filter, a Copilot-first alias group would steer every request to a +slot that answers `provider_not_configured`, producing a 100% call-failure rate +and, for retry-happy clients, a non-terminating retry loop. + +A `provider_not_configured` response is a terminal run-level misconfiguration: +it is returned with HTTP `403` and `"retryable": false` so clients fail fast. +Only transient OIDC readiness states, such as a token that has not been minted +yet, use HTTP `503` and `"retryable": true`. + ## 13. Model Alias Logging The API proxy emits structured logging events during model alias resolution. diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index adb5df7cc..1cafc53cd 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -84,7 +84,7 @@ }, "enableTokenSteering": { "type": "boolean", - "description": "Enable effective token budget steering. When true, the proxy injects budget-warning system messages at 80%, 90%, 95%, and 99% usage to nudge the agent to wrap up. Requires maxEffectiveTokens. Default: false." + "description": "Enable effective token budget steering. When true, the proxy injects budget-warning system messages at 80%, 90%, 95%, and 99% usage to nudge the agent to wrap up, and sets AWF_ENABLE_TOKEN_STEERING=true in the api-proxy sidecar. Set to false (or omit) to opt out. Requires maxEffectiveTokens. Default: false." }, "anthropicAutoCache": { "type": "boolean", diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index adb5df7cc..1cafc53cd 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -84,7 +84,7 @@ }, "enableTokenSteering": { "type": "boolean", - "description": "Enable effective token budget steering. When true, the proxy injects budget-warning system messages at 80%, 90%, 95%, and 99% usage to nudge the agent to wrap up. Requires maxEffectiveTokens. Default: false." + "description": "Enable effective token budget steering. When true, the proxy injects budget-warning system messages at 80%, 90%, 95%, and 99% usage to nudge the agent to wrap up, and sets AWF_ENABLE_TOKEN_STEERING=true in the api-proxy sidecar. Set to false (or omit) to opt out. Requires maxEffectiveTokens. Default: false." }, "anthropicAutoCache": { "type": "boolean",