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
31 changes: 29 additions & 2 deletions containers/api-proxy/adapter-factory.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

'use strict';

const { normalizeApiTarget, normalizeBasePath } = require('./proxy-utils');
const { normalizeApiTarget, normalizeBasePath, makeUnconfiguredHealthResponse } = require('./proxy-utils');

/**
*
Expand Down Expand Up @@ -204,7 +204,10 @@ function createProviderAuthScaffold(env, deps = {}, { keyEnvVar, targetEnvVar, b
* @param {(() => boolean)} [opts.isEnabled] - Optional isEnabled override (must be provided either here or via `extra`)
* @param {((url: string) => string)} [opts.transformRequestUrl] - Optional URL transformer
* @param {(() => import('./providers/index').UnconfiguredResponse)} [opts.getUnconfiguredResponse] - Optional not-configured response
* @param {(() => import('./providers/index').UnconfiguredResponse)} [opts.getUnconfiguredHealthResponse] - Optional not-configured /health 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
* @param {string} [opts.missingCredentialMessage] - Default error message when credentials are absent; requires healthServiceName
* @param {(() => { message: string, status?: string }|null)} [opts.unavailableWhen] - Optional callback; when it returns a non-null object the /health response uses that message (and optional status, e.g. 'unavailable') instead of missingCredentialMessage
* @param {object} [opts.extra={}] - Extra fields spread into the returned object last (e.g. OIDC runtime methods, introspection fields, overrides)
* @returns {import('./providers/index').ProviderAdapter}
*/
Expand All @@ -220,8 +223,32 @@ function buildProviderAdapter({
transformRequestUrl,
getUnconfiguredResponse,
getUnconfiguredHealthResponse,
healthServiceName,
missingCredentialMessage,
unavailableWhen,
extra = {},
}) {
// Auto-generate getUnconfiguredHealthResponse from declarative metadata when
// no explicit function is provided. The optional unavailableWhen callback
// allows providers with OIDC to surface a dynamic message/status.
const hasDeclarativeHealthMetadata =
healthServiceName !== undefined || missingCredentialMessage !== undefined || unavailableWhen !== undefined;
if (getUnconfiguredHealthResponse === undefined && hasDeclarativeHealthMetadata) {
if (healthServiceName === undefined || missingCredentialMessage === undefined) {
throw new TypeError(
`Provider adapter "${name}" declarative health metadata requires both healthServiceName and missingCredentialMessage`,
);
}
getUnconfiguredHealthResponse = () => {
if (unavailableWhen) {
const override = unavailableWhen();
if (override) {
return makeUnconfiguredHealthResponse(healthServiceName, override.message, override.status);
}
}
return makeUnconfiguredHealthResponse(healthServiceName, missingCredentialMessage);
};
}
const adapter = {
name,
port,
Expand Down
96 changes: 96 additions & 0 deletions containers/api-proxy/adapter-factory.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,102 @@ describe('buildProviderAdapter', () => {
});
expect(adapter.getUnconfiguredHealthResponse).toBe(getUnconfiguredHealthResponse);
});

it('auto-generates getUnconfiguredHealthResponse from healthServiceName and missingCredentialMessage', () => {
const adapter = buildProviderAdapter({
name: 'test',
port: 10099,
adapterMethods: makeAdapterMethods(),
getAuthHeaders() { return {}; },
isEnabled() { return false; },
healthServiceName: 'awf-api-proxy-test',
missingCredentialMessage: 'TEST_API_KEY not configured in api-proxy sidecar',
});
expect(typeof adapter.getUnconfiguredHealthResponse).toBe('function');
const response = adapter.getUnconfiguredHealthResponse();
expect(response.statusCode).toBe(503);
expect(response.body.status).toBe('not_configured');
expect(response.body.service).toBe('awf-api-proxy-test');
expect(response.body.error).toBe('TEST_API_KEY not configured in api-proxy sidecar');
});

it('throws when declarative health metadata is partially specified', () => {
expect(() => buildProviderAdapter({
name: 'test',
port: 10099,
adapterMethods: makeAdapterMethods(),
getAuthHeaders() { return {}; },
isEnabled() { return false; },
healthServiceName: 'awf-api-proxy-test',
})).toThrow('declarative health metadata requires both healthServiceName and missingCredentialMessage');

expect(() => buildProviderAdapter({
name: 'test',
port: 10099,
adapterMethods: makeAdapterMethods(),
getAuthHeaders() { return {}; },
isEnabled() { return false; },
missingCredentialMessage: 'TEST_API_KEY not configured in api-proxy sidecar',
})).toThrow('declarative health metadata requires both healthServiceName and missingCredentialMessage');

expect(() => buildProviderAdapter({
name: 'test',
port: 10099,
adapterMethods: makeAdapterMethods(),
getAuthHeaders() { return {}; },
isEnabled() { return false; },
unavailableWhen: () => ({ message: 'OIDC token unavailable' }),
})).toThrow('declarative health metadata requires both healthServiceName and missingCredentialMessage');
});

it('auto-generated getUnconfiguredHealthResponse uses unavailableWhen override when it returns truthy', () => {
const adapter = buildProviderAdapter({
name: 'test',
port: 10099,
adapterMethods: makeAdapterMethods(),
getAuthHeaders() { return {}; },
isEnabled() { return false; },
healthServiceName: 'awf-api-proxy-test',
missingCredentialMessage: 'TEST_API_KEY not configured in api-proxy sidecar',
unavailableWhen: () => ({ message: 'OIDC token unavailable', status: 'unavailable' }),
});
const response = adapter.getUnconfiguredHealthResponse();
expect(response.statusCode).toBe(503);
expect(response.body.status).toBe('unavailable');
expect(response.body.service).toBe('awf-api-proxy-test');
expect(response.body.error).toBe('OIDC token unavailable');
});

it('auto-generated getUnconfiguredHealthResponse falls back to missingCredentialMessage when unavailableWhen returns null', () => {
const adapter = buildProviderAdapter({
name: 'test',
port: 10099,
adapterMethods: makeAdapterMethods(),
getAuthHeaders() { return {}; },
isEnabled() { return false; },
healthServiceName: 'awf-api-proxy-test',
missingCredentialMessage: 'TEST_API_KEY not configured in api-proxy sidecar',
unavailableWhen: () => null,
});
const response = adapter.getUnconfiguredHealthResponse();
expect(response.body.status).toBe('not_configured');
expect(response.body.error).toBe('TEST_API_KEY not configured in api-proxy sidecar');
});

it('explicit getUnconfiguredHealthResponse takes precedence over declarative metadata', () => {
const explicitFn = () => ({ statusCode: 503, body: { status: 'custom' } });
const adapter = buildProviderAdapter({
name: 'test',
port: 10099,
adapterMethods: makeAdapterMethods(),
getAuthHeaders() { return {}; },
isEnabled() { return false; },
getUnconfiguredHealthResponse: explicitFn,
healthServiceName: 'awf-api-proxy-test',
missingCredentialMessage: 'TEST_API_KEY not configured',
});
expect(adapter.getUnconfiguredHealthResponse).toBe(explicitFn);
});
});

describe('extra fields', () => {
Expand Down
7 changes: 6 additions & 1 deletion containers/api-proxy/providers/ADDING-A-PROVIDER.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,12 @@ module.exports = { createMyProviderAdapter };
| `getModelsFetchConfig()` | ✅ | Returns fetch config or `null` |
| `getReflectionInfo()` | ✅ | Returns endpoint metadata for `/reflect` and `models.json` |
| `getUnconfiguredResponse()` | ➖ optional | Response for proxy requests when `alwaysBind=true` & not enabled |
| `getUnconfiguredHealthResponse()` | ➖ optional | `/health` response when not enabled (defaults to 503) |
| `getUnconfiguredHealthResponse()` | ➖ optional | `/health` response when not enabled — prefer the declarative form below |
| `healthServiceName` | ➖ optional | Service name for auto-generated `/health` response (e.g. `'awf-api-proxy-myprovider'`); requires `missingCredentialMessage` |
| `missingCredentialMessage` | ➖ optional | Default error message when credentials are absent (requires `healthServiceName`) |
| `unavailableWhen` | ➖ optional | `() => ({ message: 'OIDC token unavailable', status: 'unavailable' })` or `() => null` — when non-null the auto-generated `/health` response uses the returned message/status (e.g. for OIDC token-not-yet-available states) |

> **Tip:** Pass `healthServiceName` + `missingCredentialMessage` (and optionally `unavailableWhen`) to `buildProviderAdapter` instead of writing a `getUnconfiguredHealthResponse()` method. The factory auto-generates the method from those values, keeping provider files free of repetitive boilerplate.

---

Expand Down
11 changes: 3 additions & 8 deletions containers/api-proxy/providers/anthropic.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
const {
composeBodyTransforms,
makeProviderNotConfiguredResponse,
makeUnconfiguredHealthResponse,
} = require('../proxy-utils');
const {
validateAuthHeaderEnv,
Expand Down Expand Up @@ -222,13 +221,9 @@ function createAnthropicAdapter(env, deps = {}) {
'Credentials for Anthropic (port 10001) are not configured. Set ANTHROPIC_API_KEY to enable this provider.'
);
},
/** /health response when not configured. */
getUnconfiguredHealthResponse() {
if (oidcRequested) {
return makeUnconfiguredHealthResponse('awf-api-proxy-anthropic', oidcUnavailableError, 'unavailable');
}
return makeUnconfiguredHealthResponse('awf-api-proxy-anthropic', 'ANTHROPIC_API_KEY not configured in api-proxy sidecar');
},
healthServiceName: 'awf-api-proxy-anthropic',
missingCredentialMessage: 'ANTHROPIC_API_KEY not configured in api-proxy sidecar',
unavailableWhen: () => oidcRequested ? { message: oidcUnavailableError, status: 'unavailable' } : null,
extra: {
...oidcRuntimeMethods,
// Exposed for introspection (logging, tests)
Expand Down
11 changes: 3 additions & 8 deletions containers/api-proxy/providers/copilot.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
const {
normalizeBasePath,
makeProviderNotConfiguredResponse,
makeUnconfiguredHealthResponse,
composeBodyTransforms,
} = require('../proxy-utils');
const { resolveOidcAuthHeaders } = require('../oidc-adapter-utils');
Expand Down Expand Up @@ -262,13 +261,9 @@ function buildCopilotModelsRequest(extra = {}) {
'Credentials for GitHub Copilot (port 10002) are not configured. Set COPILOT_GITHUB_TOKEN or COPILOT_PROVIDER_API_KEY to enable this provider.'
);
},
/** /health response when not configured. */
getUnconfiguredHealthResponse() {
if (oidcConfigured) {
return makeUnconfiguredHealthResponse('awf-api-proxy-copilot', `Copilot OIDC token (${authProvider}) not yet available in api-proxy sidecar`);
}
return makeUnconfiguredHealthResponse('awf-api-proxy-copilot', 'COPILOT_GITHUB_TOKEN or COPILOT_PROVIDER_API_KEY not configured in api-proxy sidecar');
},
healthServiceName: 'awf-api-proxy-copilot',
missingCredentialMessage: 'COPILOT_GITHUB_TOKEN or COPILOT_PROVIDER_API_KEY not configured in api-proxy sidecar',
unavailableWhen: () => oidcConfigured ? { message: `Copilot OIDC token (${authProvider}) not yet available in api-proxy sidecar` } : null,
extra: {
...oidcRuntimeMethods,
// Exposed for introspection / testing
Expand Down
8 changes: 3 additions & 5 deletions containers/api-proxy/providers/gemini.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* Gemini SDK versions append alongside the header.
*/

const { stripGeminiKeyParam, makeUnconfiguredHealthResponse } = require('../proxy-utils');
const { stripGeminiKeyParam } = require('../proxy-utils');
const { createProviderAuthScaffold, createAdapterMethods, buildProviderAdapter } = require('../adapter-factory');
const { GEMINI_ENV } = require('../provider-env-constants');
const { providerKeyHeaders } = require('./auth-headers');
Expand Down Expand Up @@ -75,10 +75,8 @@ function createGeminiAdapter(env, deps = {}) {
body: { error: 'Gemini proxy not configured (no GEMINI_API_KEY). Set GEMINI_API_KEY in the AWF runner environment to enable credential isolation.' },
};
},
/** /health response when not configured. */
getUnconfiguredHealthResponse() {
return makeUnconfiguredHealthResponse('awf-api-proxy-gemini', 'GEMINI_API_KEY not configured in api-proxy sidecar');
},
healthServiceName: 'awf-api-proxy-gemini',
missingCredentialMessage: 'GEMINI_API_KEY not configured in api-proxy sidecar',
});
}

Expand Down
7 changes: 2 additions & 5 deletions containers/api-proxy/providers/vertex.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
* aiplatform.googleapis.com directly, enabling credential isolation.
*/

const { makeUnconfiguredHealthResponse } = require('../proxy-utils');
const { createProviderAuthScaffold, createAdapterMethods, buildProviderAdapter } = require('../adapter-factory');
const { VERTEX_ENV } = require('../provider-env-constants');
const { providerKeyHeaders } = require('./auth-headers');
Expand Down Expand Up @@ -67,10 +66,8 @@ function createVertexAdapter(env, deps = {}) {
body: { error: 'Vertex AI proxy not configured (no GOOGLE_API_KEY). Set GOOGLE_API_KEY in the AWF runner environment to enable credential isolation.' },
};
},
/** /health response when not configured. */
getUnconfiguredHealthResponse() {
return makeUnconfiguredHealthResponse('awf-api-proxy-vertex', 'GOOGLE_API_KEY not configured in api-proxy sidecar');
},
healthServiceName: 'awf-api-proxy-vertex',
missingCredentialMessage: 'GOOGLE_API_KEY not configured in api-proxy sidecar',
});
}

Expand Down
Loading