Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
13 changes: 9 additions & 4 deletions containers/api-proxy/adapter-factory.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,18 @@ const {
* }|{
* kind: 'provider_not_configured',
* message: string,
* statusCode?: number
* statusCode?: number,
* retryable?: boolean
* }} spec
* @returns {import('./providers/index').UnconfiguredResponse}
*/
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;
}
Expand Down Expand Up @@ -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',
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion containers/api-proxy/adapter-factory.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
});
Expand Down
3 changes: 3 additions & 0 deletions containers/api-proxy/copilot-adapter-enterprise.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
18 changes: 14 additions & 4 deletions containers/api-proxy/model-config.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -166,6 +175,7 @@ function makeModelBodyTransform(provider, cachedModels, refreshProviderModelsFor

module.exports = {
MODEL_ALIASES,
filterAvailableModelsToConfiguredProviders,
MODEL_FALLBACK,
MODEL_POLICY_CONFIG,
parseModelFallbackConfig,
Expand Down
2 changes: 1 addition & 1 deletion containers/api-proxy/model-discovery.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
100 changes: 94 additions & 6 deletions containers/api-proxy/model-resolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -371,38 +372,125 @@ function resolveModel(
*
* @param {Record<string, string[]|{patterns: string[], fallback?: boolean}>} aliases
* @param {Record<string, string[]|null>} availableModels - Cached models per provider (null = not yet fetched)
* @param {Set<string>|string[]|null|undefined} [configuredProviders] - Provider cache keys that are configured
* @returns {Record<string, string[]|{patterns: string[], fallback?: boolean}>}
*/
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];
}
}

return result;
}

/**
* Conservatively determine whether an alias can target any configured provider
* before provider model catalogues are available.
*
* @param {string} aliasKey
* @param {Record<string, string[]|{patterns: string[], fallback?: boolean}>} aliases
* @param {Set<string>} configuredProviders
* @param {Set<string>} [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<string, string[]|null>} availableModels
* @param {Set<string>|string[]|null|undefined} configuredProviders - Provider cache keys that are configured
* @returns {Record<string, string[]|null>}
*/
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,
Expand Down
107 changes: 107 additions & 0 deletions containers/api-proxy/model-resolver.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const {
parseModelAliases,
selectMiddlePowerFallback,
filterResolvableAliases,
filterAvailableModelsToConfiguredProviders,
resolveModel,
} = require('./model-resolver');
const { rewriteModelInBody } = require('./model-body-rewriter');
Expand Down Expand Up @@ -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({});
});
});
5 changes: 4 additions & 1 deletion containers/api-proxy/oidc-adapter-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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; },
Expand Down
Loading
Loading