diff --git a/.github/workflows/model-api-mapping-updater.lock.yml b/.github/workflows/model-api-mapping-updater.lock.yml index 4058a0ff8..fa546cca4 100644 --- a/.github/workflows/model-api-mapping-updater.lock.yml +++ b/.github/workflows/model-api-mapping-updater.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"283cc33b0c821741daf5854e92526c51705396e9f9ce80a77985ba4438d0c67c","body_hash":"d422abdecb7fbeaec44a212ad81b8571fb7b5a4311b0e6fbc6bd4e3c41dcf8cb","compiler_version":"v0.81.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.65"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"2838ff76260c7b5b380efab386be4e31e6e903e49c441d95fe9cdd250f47e89c","body_hash":"d422abdecb7fbeaec44a212ad81b8571fb7b5a4311b0e6fbc6bd4e3c41dcf8cb","compiler_version":"v0.81.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.65"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"ba6380cc6e5be5d21677bebe04d52fb48e3abec7","version":"v0.81.6"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.11","digest":"sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.11@sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.11","digest":"sha256:807e4831999b44513b0a66e5859d478dc4da7ae74ab1918cec967d513f95bf9d","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.11@sha256:807e4831999b44513b0a66e5859d478dc4da7ae74ab1918cec967d513f95bf9d"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.11","digest":"sha256:1f989a7f2869c6a1b5f76bbbc645027966aa7e87b066d94734a901a7f371dfcd","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.11@sha256:1f989a7f2869c6a1b5f76bbbc645027966aa7e87b066d94734a901a7f371dfcd"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.11","digest":"sha256:ff27ea0525ad953a6adee28a5fbe9d2e22be47dbec755c15767af4ea3f91df7d","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.11@sha256:ff27ea0525ad953a6adee28a5fbe9d2e22be47dbec755c15767af4ea3f91df7d"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.30","digest":"sha256:35625d1a2269b1238606078c879f59a91cffc4ac33eb54bf39c6418822c1a8be","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.30@sha256:35625d1a2269b1238606078c879f59a91cffc4ac33eb54bf39c6418822c1a8be"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw (v0.81.6). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -382,6 +382,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + issues: read + pull-requests: read concurrency: group: "gh-aw-copilot-${{ github.workflow }}" queue: max diff --git a/.github/workflows/model-api-mapping-updater.md b/.github/workflows/model-api-mapping-updater.md index b95eadbbb..c3f0737c7 100644 --- a/.github/workflows/model-api-mapping-updater.md +++ b/.github/workflows/model-api-mapping-updater.md @@ -6,10 +6,14 @@ on: workflow_dispatch: permissions: contents: read + pull-requests: read + issues: read tools: github: mode: gh-proxy toolsets: [repos] + bash: true + edit: network: allowed: - defaults diff --git a/containers/api-proxy/Dockerfile b/containers/api-proxy/Dockerfile index 11bedb74f..53a3fbba7 100644 --- a/containers/api-proxy/Dockerfile +++ b/containers/api-proxy/Dockerfile @@ -31,7 +31,8 @@ COPY server.js logging.js metrics.js rate-limiter.js rate-limiter-window.js \ upstream-log.js upstream-retry.js upstream-token.js \ anthropic-cache.js otel.js otel-exporters.js otel-serialization.js \ token-budget-log.js blocked-request-diagnostics.js \ - provider-env-constants.js provider-names.js ./ + provider-env-constants.js provider-names.js \ + model-api-mapping.js model-api-mapping.json ./ COPY guards/ ./guards/ COPY providers/ ./providers/ COPY transforms/ ./transforms/ diff --git a/containers/api-proxy/management.js b/containers/api-proxy/management.js index 04cf55ddd..2c367a414 100644 --- a/containers/api-proxy/management.js +++ b/containers/api-proxy/management.js @@ -15,6 +15,7 @@ */ const metrics = require('./metrics'); +const { getModelApiMappingReflect } = require('./model-api-mapping'); /** * @typedef {object} ManagementDeps @@ -109,6 +110,7 @@ function createManagementHandlers(deps) { runs: getMaxRunsUsage(), cache_misses: getMaxCacheMissesUsage(), permission_denied: getPermissionDeniedUsage(), + model_api_mapping: getModelApiMappingReflect(), }; } diff --git a/containers/api-proxy/model-api-mapping.js b/containers/api-proxy/model-api-mapping.js new file mode 100644 index 000000000..ba4953791 --- /dev/null +++ b/containers/api-proxy/model-api-mapping.js @@ -0,0 +1,145 @@ +'use strict'; + +/** + * AWF API Proxy — Model-to-API Endpoint Mapping + * + * Loads the model-api-mapping.json reference file and exposes it for + * the /reflect management endpoint. This mapping documents which API + * endpoints each model family supports (e.g. responses-only vs + * chat/completions vs both). + * + * The mapping is informational — it does not alter proxy routing behavior. + * Consumers (e.g. SDK drivers, harness scripts) can query /reflect to + * determine the correct endpoint for a given model. + */ + +const fs = require('fs'); +const path = require('path'); + +/** + * Paths to search for the mapping file, in priority order. + * The first path that exists wins. + */ +const MAPPING_FILE_SEARCH_PATHS = [ + // Injected via env var (e.g. in CI or Docker) + process.env.AWF_MODEL_API_MAPPING_PATH, + // Relative to the api-proxy container directory + path.join(__dirname, 'model-api-mapping.json'), + // Relative to the repo root (local dev) + path.join(__dirname, '../../docs/model-api-mapping.json'), +]; + +let _mapping = null; +let _loadError = null; + +/** + * Attempt to load the model-api-mapping.json from the search paths. + * Called once at module load time. Safe to call again to reload. + */ +function loadMapping() { + for (const filePath of MAPPING_FILE_SEARCH_PATHS) { + if (!filePath) continue; + try { + if (fs.existsSync(filePath)) { + const raw = fs.readFileSync(filePath, 'utf8'); + _mapping = JSON.parse(raw); + _loadError = null; + return; + } + } catch (err) { + _loadError = err.message; + } + } + // Not found in any search path — this is fine, mapping is optional + _mapping = null; + if (!_loadError) { + _loadError = 'model-api-mapping.json not found in search paths'; + } +} + +// Load on first require +loadMapping(); + +/** + * Get the loaded mapping object, or null if not available. + * @returns {object|null} + */ +function getModelApiMapping() { + return _mapping; +} + +/** + * Look up the supported endpoints for a given model string. + * Returns the matching entry or null if no match is found. + * + * @param {string} model - Model identifier (e.g. "gpt-5.5", "claude-sonnet-4-6") + * @param {string} [provider] - Optional provider hint ("openai" or "anthropic") + * @returns {{ family: string, endpoints: string[], notes: string } | null} + */ +function lookupModelEndpoints(model, provider) { + if (!_mapping || !model) return null; + + const providers = provider + ? [_mapping.providers[provider]].filter(Boolean) + : Object.values(_mapping.providers || {}); + + for (const prov of providers) { + if (!prov || !Array.isArray(prov.models)) continue; + for (const entry of prov.models) { + if (!entry.patterns) continue; + for (const pattern of entry.patterns) { + if (matchesGlobPattern(model, pattern)) { + return { + family: entry.family, + endpoints: entry.endpoints, + notes: entry.notes || '', + }; + } + } + } + } + return null; +} + +/** + * Simple glob matching: supports trailing `*` wildcard only. + * @param {string} value + * @param {string} pattern + * @returns {boolean} + */ +function matchesGlobPattern(value, pattern) { + if (pattern.endsWith('*')) { + return value.startsWith(pattern.slice(0, -1)); + } + return value === pattern; +} + +/** + * Get the reflect-friendly summary for inclusion in /reflect response. + * @returns {{ available: boolean, last_updated: string|null, providers: string[], error: string|null }} + */ +function getModelApiMappingReflect() { + if (!_mapping) { + return { + available: false, + last_updated: null, + providers: [], + error: _loadError, + }; + } + return { + available: true, + last_updated: _mapping.lastUpdated || null, + providers: Object.keys(_mapping.providers || {}), + models: _mapping.providers, + error: null, + }; +} + +module.exports = { + getModelApiMapping, + getModelApiMappingReflect, + lookupModelEndpoints, + loadMapping, + matchesGlobPattern, +}; diff --git a/containers/api-proxy/model-api-mapping.json b/containers/api-proxy/model-api-mapping.json new file mode 100644 index 000000000..c55d56b24 --- /dev/null +++ b/containers/api-proxy/model-api-mapping.json @@ -0,0 +1,155 @@ +{ + "title": "Model-to-API Endpoint Mapping", + "description": "Maps AI model families to their supported API endpoints. Used to determine which endpoint (chat/completions vs responses vs messages) a model requires.", + "lastUpdated": "2026-06-30T04:00:00Z", + "sources": { + "openai": "https://platform.openai.com/docs/models", + "anthropic": "https://docs.anthropic.com/en/docs/about-claude/models" + }, + "providers": { + "openai": { + "endpoints": { + "responses": { + "path": "/v1/responses", + "description": "Newer unified API for agentic, tool-using, and multimodal workflows. Required for GPT-5+ models; some older models (o1, o3, gpt-4o, gpt-4.1) support it alongside chat/completions." + }, + "chat_completions": { + "path": "/v1/chat/completions", + "description": "Legacy conversational API. Being deprecated for newer models by end of 2026." + } + }, + "models": [ + { + "family": "gpt-5.5", + "patterns": ["gpt-5.5*"], + "endpoints": ["responses"], + "notes": "Responses API only. Not accessible via /chat/completions." + }, + { + "family": "gpt-5.4", + "patterns": ["gpt-5.4*"], + "endpoints": ["responses"], + "notes": "Responses API only." + }, + { + "family": "gpt-5.3", + "patterns": ["gpt-5.3*"], + "endpoints": ["responses"], + "notes": "Responses API only. Includes gpt-5.3-codex variants." + }, + { + "family": "gpt-5.2", + "patterns": ["gpt-5.2*"], + "endpoints": ["responses"], + "notes": "Responses API only." + }, + { + "family": "gpt-5", + "patterns": ["gpt-5", "gpt-5-*"], + "endpoints": ["responses"], + "notes": "Responses API only. Includes gpt-5-mini, gpt-5-nano, gpt-5-pro." + }, + { + "family": "o4", + "patterns": ["o4*"], + "endpoints": ["responses"], + "notes": "Reasoning model. Responses API only. Includes o4-mini." + }, + { + "family": "o3", + "patterns": ["o3*"], + "endpoints": ["chat_completions", "responses"], + "notes": "Reasoning model. Supports both endpoints." + }, + { + "family": "o1", + "patterns": ["o1*"], + "endpoints": ["chat_completions", "responses"], + "notes": "Reasoning model. Supports both endpoints." + }, + { + "family": "gpt-4o", + "patterns": ["gpt-4o*"], + "endpoints": ["chat_completions", "responses"], + "notes": "Multimodal. Supports both endpoints." + }, + { + "family": "gpt-4.1", + "patterns": ["gpt-4.1*"], + "endpoints": ["chat_completions", "responses"], + "notes": "Supports both endpoints." + }, + { + "family": "gpt-4-turbo", + "patterns": ["gpt-4-turbo*"], + "endpoints": ["chat_completions"], + "notes": "Legacy. Chat completions only." + }, + { + "family": "gpt-4", + "patterns": ["gpt-4", "gpt-4-*"], + "endpoints": ["chat_completions"], + "notes": "Legacy. Chat completions only." + }, + { + "family": "gpt-3.5-turbo", + "patterns": ["gpt-3.5-turbo*"], + "endpoints": ["chat_completions"], + "notes": "Legacy. Chat completions only. Deprecated." + } + ] + }, + "anthropic": { + "endpoints": { + "messages": { + "path": "/v1/messages", + "description": "Unified Messages API for all Claude models. Supports tool use, streaming, extended thinking, and vision." + } + }, + "models": [ + { + "family": "claude-fable-5", + "patterns": ["claude-fable-5*"], + "endpoints": ["messages"], + "notes": "Latest flagship. 1M token context." + }, + { + "family": "claude-opus-4", + "patterns": ["claude-opus-4*"], + "endpoints": ["messages"], + "notes": "Includes claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-opus-4-5. 1M token context." + }, + { + "family": "claude-sonnet-4", + "patterns": ["claude-sonnet-4*"], + "endpoints": ["messages"], + "notes": "Includes claude-sonnet-4-6, claude-sonnet-4-5. Production workhorse. 1M token context." + }, + { + "family": "claude-haiku-4", + "patterns": ["claude-haiku-4*"], + "endpoints": ["messages"], + "notes": "Includes claude-haiku-4-5. Fast/cheap. 200K token context." + }, + { + "family": "claude-3.5-sonnet", + "patterns": ["claude-3-5-sonnet*", "claude-3.5-sonnet*"], + "endpoints": ["messages"], + "notes": "Previous generation. 200K token context." + }, + { + "family": "claude-3.5-haiku", + "patterns": ["claude-3-5-haiku*", "claude-3.5-haiku*"], + "endpoints": ["messages"], + "notes": "Previous generation fast model." + }, + { + "family": "claude-3-opus", + "patterns": ["claude-3-opus*"], + "endpoints": ["messages"], + "notes": "Previous generation. 200K token context." + } + ] + } + } +} diff --git a/containers/api-proxy/model-api-mapping.test.js b/containers/api-proxy/model-api-mapping.test.js new file mode 100644 index 000000000..cf8930736 --- /dev/null +++ b/containers/api-proxy/model-api-mapping.test.js @@ -0,0 +1,82 @@ +'use strict'; + +const { matchesGlobPattern, lookupModelEndpoints, getModelApiMappingReflect } = require('./model-api-mapping'); + +describe('model-api-mapping', () => { + describe('matchesGlobPattern', () => { + it('matches exact strings', () => { + expect(matchesGlobPattern('gpt-4', 'gpt-4')).toBe(true); + }); + + it('rejects non-matching exact strings', () => { + expect(matchesGlobPattern('gpt-4o', 'gpt-4')).toBe(false); + }); + + it('matches trailing wildcard', () => { + expect(matchesGlobPattern('gpt-5.5-turbo', 'gpt-5.5*')).toBe(true); + }); + + it('matches prefix-only with wildcard', () => { + expect(matchesGlobPattern('gpt-5.5', 'gpt-5.5*')).toBe(true); + }); + + it('rejects non-matching wildcard', () => { + expect(matchesGlobPattern('gpt-4o', 'gpt-5*')).toBe(false); + }); + }); + + describe('lookupModelEndpoints', () => { + it('finds GPT-5.5 as responses-only', () => { + const result = lookupModelEndpoints('gpt-5.5', 'openai'); + expect(result).not.toBeNull(); + expect(result.family).toBe('gpt-5.5'); + expect(result.endpoints).toEqual(['responses']); + }); + + it('finds GPT-5.5-turbo as responses-only', () => { + const result = lookupModelEndpoints('gpt-5.5-turbo', 'openai'); + expect(result).not.toBeNull(); + expect(result.endpoints).toEqual(['responses']); + }); + + it('finds GPT-4o as supporting both endpoints', () => { + const result = lookupModelEndpoints('gpt-4o', 'openai'); + expect(result).not.toBeNull(); + expect(result.endpoints).toContain('chat_completions'); + expect(result.endpoints).toContain('responses'); + }); + + it('finds Claude models as messages endpoint', () => { + const result = lookupModelEndpoints('claude-sonnet-4-6', 'anthropic'); + expect(result).not.toBeNull(); + expect(result.endpoints).toEqual(['messages']); + }); + + it('finds models without provider hint', () => { + const result = lookupModelEndpoints('gpt-5.5'); + expect(result).not.toBeNull(); + expect(result.endpoints).toEqual(['responses']); + }); + + it('returns null for unknown models', () => { + const result = lookupModelEndpoints('unknown-model-xyz'); + expect(result).toBeNull(); + }); + + it('returns null for empty model string', () => { + const result = lookupModelEndpoints(''); + expect(result).toBeNull(); + }); + }); + + describe('getModelApiMappingReflect', () => { + it('returns available mapping with provider list', () => { + const reflect = getModelApiMappingReflect(); + expect(reflect.available).toBe(true); + expect(reflect.providers).toContain('openai'); + expect(reflect.providers).toContain('anthropic'); + expect(reflect.last_updated).toBeTruthy(); + expect(reflect.error).toBeNull(); + }); + }); +}); diff --git a/docs/model-api-mapping.json b/docs/model-api-mapping.json index 1e41aa603..c55d56b24 100644 --- a/docs/model-api-mapping.json +++ b/docs/model-api-mapping.json @@ -1,5 +1,4 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "Model-to-API Endpoint Mapping", "description": "Maps AI model families to their supported API endpoints. Used to determine which endpoint (chat/completions vs responses vs messages) a model requires.", "lastUpdated": "2026-06-30T04:00:00Z", @@ -12,7 +11,7 @@ "endpoints": { "responses": { "path": "/v1/responses", - "description": "Newer unified API for agentic, tool-using, and multimodal workflows. Required for GPT-5+ and reasoning models." + "description": "Newer unified API for agentic, tool-using, and multimodal workflows. Required for GPT-5+ models; some older models (o1, o3, gpt-4o, gpt-4.1) support it alongside chat/completions." }, "chat_completions": { "path": "/v1/chat/completions", diff --git a/src/artifact-permissions.ts b/src/artifact-permissions.ts index 7f64c71d0..d390ac5f1 100644 --- a/src/artifact-permissions.ts +++ b/src/artifact-permissions.ts @@ -70,7 +70,7 @@ export function fixArtifactPermissionsForRootless( imageRef, 'sh', '-c', - 'chown -R "$TUID:$TGID" /fix && chmod -R a+rX /fix', + 'chown -R "$TUID:$TGID" /fix && chmod -R a+rwX /fix', ], { env: getLocalDockerEnv(), reject: false }, );