diff --git a/docs.md b/docs.md index 2cb14a6..ab77653 100644 --- a/docs.md +++ b/docs.md @@ -179,9 +179,9 @@ author: gitagent-examples license: MIT model: - preferred: claude-sonnet-4-5-20250929 + preferred: anthropic:claude-sonnet-4-5-20250929 fallback: - - claude-haiku-4-5-20251001 + - anthropic:claude-haiku-4-5-20251001 constraints: temperature: 0.2 max_tokens: 4096 @@ -213,9 +213,9 @@ author: Acme Corp license: MIT model: - preferred: claude-opus-4-6 + preferred: anthropic:claude-opus-4-6 fallback: - - claude-sonnet-4-5-20250929 + - anthropic:claude-sonnet-4-5-20250929 constraints: temperature: 0.2 max_tokens: 4096 diff --git a/examples/full/agent.yaml b/examples/full/agent.yaml index 9862280..4a278c8 100644 --- a/examples/full/agent.yaml +++ b/examples/full/agent.yaml @@ -5,9 +5,9 @@ description: Financial compliance analysis agent for FINRA and Federal Reserve r author: gitagent-examples license: proprietary model: - preferred: claude-opus-4-6 + preferred: anthropic:claude-opus-4-6 fallback: - - claude-sonnet-4-5-20250929 + - anthropic:claude-sonnet-4-5-20250929 constraints: temperature: 0.1 max_tokens: 8192 diff --git a/examples/full/tools/generate-report.yaml b/examples/full/tools/generate-report.yaml index 4295446..03d7cb1 100644 --- a/examples/full/tools/generate-report.yaml +++ b/examples/full/tools/generate-report.yaml @@ -37,7 +37,7 @@ output_schema: description: Path to saved report file implementation: type: script - path: generate-report.sh + script: generate-report.sh runtime: bash timeout: 60 annotations: diff --git a/examples/full/tools/search-regulations.yaml b/examples/full/tools/search-regulations.yaml index 95ba111..5efc978 100644 --- a/examples/full/tools/search-regulations.yaml +++ b/examples/full/tools/search-regulations.yaml @@ -50,7 +50,7 @@ output_schema: type: integer implementation: type: script - path: search-regulations.py + script: search-regulations.py runtime: python3 timeout: 30 annotations: diff --git a/examples/gitagent-helper/agent.yaml b/examples/gitagent-helper/agent.yaml index 76c83b5..e4d2585 100644 --- a/examples/gitagent-helper/agent.yaml +++ b/examples/gitagent-helper/agent.yaml @@ -5,9 +5,9 @@ description: Your AI assistant for building, running, and managing git-native AI author: gitagent license: MIT model: - preferred: claude-sonnet-4-5-20250929 + preferred: anthropic:claude-sonnet-4-5-20250929 fallback: - - claude-haiku-4-5-20251001 + - anthropic:claude-haiku-4-5-20251001 constraints: temperature: 0.2 max_tokens: 8192 diff --git a/examples/jason-my-claw-is-the-law-deebee-4567b4/agent.yaml b/examples/jason-my-claw-is-the-law-deebee-4567b4/agent.yaml index 1920aa2..54f1166 100644 --- a/examples/jason-my-claw-is-the-law-deebee-4567b4/agent.yaml +++ b/examples/jason-my-claw-is-the-law-deebee-4567b4/agent.yaml @@ -11,9 +11,9 @@ author: bengii license: MIT model: - preferred: ollama/qwen3-coder:latest + preferred: ollama:qwen3-coder:latest fallback: - - cerebras/qwen-3-235b-a22b-instruct-2507 + - cerebras:qwen-3-235b-a22b-instruct-2507 constraints: temperature: 0.3 max_tokens: 4096 diff --git a/examples/llm-wiki/agent.yaml b/examples/llm-wiki/agent.yaml index 1eebb65..4b1ef4e 100644 --- a/examples/llm-wiki/agent.yaml +++ b/examples/llm-wiki/agent.yaml @@ -5,9 +5,9 @@ description: "LLM-maintained personal wiki — a persistent, compounding knowled author: gitagent-examples license: MIT model: - preferred: claude-sonnet-4-5-20250929 + preferred: anthropic:claude-sonnet-4-5-20250929 fallback: - - claude-haiku-4-5-20251001 + - anthropic:claude-haiku-4-5-20251001 skills: - wiki-ingest - wiki-query diff --git a/examples/lyzr-agent/agent.yaml b/examples/lyzr-agent/agent.yaml index 5bb8d01..b56b91e 100644 --- a/examples/lyzr-agent/agent.yaml +++ b/examples/lyzr-agent/agent.yaml @@ -5,9 +5,9 @@ description: AI research assistant that summarizes topics, answers questions, an author: gitagent-examples license: MIT model: - preferred: gpt-4.1 + preferred: openai:gpt-4.1 fallback: - - gpt-4.1-mini + - openai:gpt-4.1-mini constraints: temperature: 0.3 max_tokens: 4096 diff --git a/examples/nvidia-deep-researcher/agent.yaml b/examples/nvidia-deep-researcher/agent.yaml index d625412..8b1d070 100644 --- a/examples/nvidia-deep-researcher/agent.yaml +++ b/examples/nvidia-deep-researcher/agent.yaml @@ -7,9 +7,9 @@ description: > structured TOC, and verified sources using a coordinated orchestrator-planner-researcher hierarchy. model: - preferred: openai/gpt-oss-120b + preferred: openai:gpt-oss-120b fallback: - - nvidia/nemotron-3-super-120b-a12b + - nvidia:nemotron-3-super-120b-a12b constraints: temperature: 0.2 max_tokens: 16384 diff --git a/examples/standard/agent.yaml b/examples/standard/agent.yaml index a1af7d2..d263150 100644 --- a/examples/standard/agent.yaml +++ b/examples/standard/agent.yaml @@ -5,9 +5,9 @@ description: Automated code review agent with best-practice enforcement author: gitagent-examples license: MIT model: - preferred: claude-sonnet-4-5-20250929 + preferred: anthropic:claude-sonnet-4-5-20250929 fallback: - - claude-haiku-4-5-20251001 + - anthropic:claude-haiku-4-5-20251001 constraints: temperature: 0.2 max_tokens: 4096 diff --git a/examples/standard/tools/complexity-analysis.yaml b/examples/standard/tools/complexity-analysis.yaml index b1dbb3c..d77cecb 100644 --- a/examples/standard/tools/complexity-analysis.yaml +++ b/examples/standard/tools/complexity-analysis.yaml @@ -27,7 +27,7 @@ output_schema: exceeds_threshold: { type: boolean } implementation: type: script - path: complexity-analysis.sh + script: complexity-analysis.sh runtime: bash timeout: 30 annotations: diff --git a/examples/standard/tools/lint-check.yaml b/examples/standard/tools/lint-check.yaml index 487d308..0dc7b03 100644 --- a/examples/standard/tools/lint-check.yaml +++ b/examples/standard/tools/lint-check.yaml @@ -27,7 +27,7 @@ output_schema: rule: { type: string } implementation: type: script - path: lint-check.sh + script: lint-check.sh runtime: bash timeout: 30 annotations: diff --git a/spec/SPECIFICATION.md b/spec/SPECIFICATION.md index d3f8021..ecca897 100644 --- a/spec/SPECIFICATION.md +++ b/spec/SPECIFICATION.md @@ -95,7 +95,7 @@ All YAML keys use **snake_case**. Agent names, skill names, and tool names use * | `author` | string | Author name or organization | | `license` | string | SPDX license identifier | | `model` | object | Model preferences (see Model section) | -| `model.preferred` | string | Primary model ID (e.g., `claude-opus-4-6`, `gpt-4o`) | +| `model.preferred` | string | Primary model ID (e.g., `anthropic:claude-opus-4-6`, `openai:gpt-4o`) | | `model.fallback` | string[] | Fallback model IDs in priority order | | `model.constraints` | object | Parameters: `temperature`, `max_tokens`, `top_p`, `top_k`, `stop_sequences`, `presence_penalty`, `frequency_penalty` | | `extends` | string | Parent agent (git URL or local path) | @@ -279,9 +279,9 @@ description: Financial compliance analysis agent author: Acme Financial license: proprietary model: - preferred: claude-opus-4-6 + preferred: anthropic:claude-opus-4-6 fallback: - - claude-sonnet-4-5-20250929 + - anthropic:claude-sonnet-4-5-20250929 constraints: temperature: 0.1 max_tokens: 8192 @@ -606,7 +606,7 @@ output_schema: url: { type: string } implementation: type: script - path: search-regulations.py + script: search-regulations.py runtime: python3 timeout: 30 annotations: @@ -775,7 +775,7 @@ agents/ name: fact-checker description: Verifies claims against authoritative sources model: - preferred: claude-haiku-4-5-20251001 + preferred: anthropic:claude-haiku-4-5-20251001 delegation: mode: auto triggers: diff --git a/spec/schemas/agent-yaml.schema.json b/spec/schemas/agent-yaml.schema.json index a84c21b..f788e85 100644 --- a/spec/schemas/agent-yaml.schema.json +++ b/spec/schemas/agent-yaml.schema.json @@ -40,12 +40,13 @@ "properties": { "preferred": { "type": "string", - "description": "Primary model ID (e.g., claude-opus-4-6, gpt-4o)" + "pattern": "^[a-z0-9-]+:.+$", + "description": "Primary model in canonical 'provider:model' form (e.g., anthropic:claude-opus-4-8, openai:gpt-4o)" }, "fallback": { "type": "array", - "items": { "type": "string" }, - "description": "Fallback model IDs in priority order" + "items": { "type": "string", "pattern": "^[a-z0-9-]+:.+$" }, + "description": "Fallback models in canonical 'provider:model' form, priority order" }, "constraints": { "type": "object", diff --git a/spec/schemas/tool.schema.json b/spec/schemas/tool.schema.json index c291860..2b27e4f 100644 --- a/spec/schemas/tool.schema.json +++ b/spec/schemas/tool.schema.json @@ -62,9 +62,13 @@ "enum": ["script", "mcp_server", "http"], "description": "'script': local executable. 'mcp_server': MCP server tool. 'http': REST API endpoint." }, + "script": { + "type": "string", + "description": "Path to implementation script (relative to tools/). Preferred field for type: script." + }, "path": { "type": "string", - "description": "Path to implementation script (relative to tools/)" + "description": "DEPRECATED alias for 'script'. Use 'script' instead — some runtimes (e.g. gitagent) only read 'script'." }, "runtime": { "type": "string", @@ -96,7 +100,13 @@ "allOf": [ { "if": { "properties": { "type": { "const": "script" } } }, - "then": { "required": ["path", "runtime"] } + "then": { + "required": ["runtime"], + "anyOf": [ + { "required": ["script"] }, + { "required": ["path"] } + ] + } }, { "if": { "properties": { "type": { "const": "http" } } }, diff --git a/src/adapters/codex.ts b/src/adapters/codex.ts index 27dc0fe..88ea990 100644 --- a/src/adapters/codex.ts +++ b/src/adapters/codex.ts @@ -3,6 +3,7 @@ import { join, resolve } from 'node:path'; import yaml from 'js-yaml'; import { loadAgentManifest, loadFileIfExists } from '../utils/loader.js'; import { loadAllSkills, getAllowedTools } from '../utils/skill-loader.js'; +import { parseModel } from '../utils/model.js'; import { buildComplianceSection, buildMcpServersConfig } from './shared.js'; /** @@ -171,17 +172,13 @@ function buildInstructions( function buildConfig(manifest: ReturnType): Record { const config: Record = {}; - // Map model preference to Codex CLI model format - // Codex CLI config.json accepts: { model: "string", provider?: "openai|azure|..." } + // Canonical "provider:model" → Codex CLI { model, provider? } + // Codex defaults to openai; any non-openai provider is reached via its openai-compatible endpoint. if (manifest.model?.preferred) { - const model = manifest.model.preferred; - config.model = model; - - // Add provider hint when it can be inferred from the model name - const provider = inferProvider(model); - if (provider !== 'openai') { - // Only emit provider when non-default — Codex defaults to openai - config.provider = provider; + const { provider, modelId } = parseModel(manifest.model.preferred); + config.model = modelId; + if (provider !== 'openai' && provider !== 'openai-codex') { + config.provider = 'openai-compatible'; } } @@ -194,16 +191,3 @@ function buildConfig(manifest: ReturnType): Record = {}; // Model preference - Gemini CLI expects object format + // Canonical "provider:model" → { id: model, provider } if (manifest.model?.preferred) { - // Extract provider from model name or default to google - const modelName = manifest.model.preferred; - const provider = modelName.includes('claude') ? 'anthropic' : - modelName.includes('gpt') ? 'openai' : 'google'; - + const { provider, modelId } = parseModel(manifest.model.preferred); settings.model = { - id: modelName, + id: modelId, provider: provider }; } diff --git a/src/adapters/gitclaw.ts b/src/adapters/gitclaw.ts index 7ebe63b..628e7c2 100644 --- a/src/adapters/gitclaw.ts +++ b/src/adapters/gitclaw.ts @@ -3,6 +3,7 @@ import { join, resolve } from 'node:path'; import yaml from 'js-yaml'; import { loadAgentManifest, loadFileIfExists } from '../utils/loader.js'; import { loadAllSkills } from '../utils/skill-loader.js'; +import { parseModel } from '../utils/model.js'; /** * Export a gitagent to gitclaw format. @@ -152,26 +153,14 @@ function buildAgentYaml( } /** - * Convert gitagent model name to gitclaw "provider:model-id" format. - * gitagent: "claude-sonnet-4-5" or "anthropic/claude-sonnet-4-5" - * gitclaw: "anthropic:claude-sonnet-4-5" + * Normalize the canonical "provider:model" source into gitclaw "provider:model-id" form. + * Both use the same colon convention, so this validates and re-emits the value. + * source: "anthropic:claude-sonnet-4-5" + * gitclaw: "anthropic:claude-sonnet-4-5" */ function toGitclawModel(model: string): string { - // Already in provider:model format - if (model.includes(':') && !model.includes('://')) return model; - - // provider/model → provider:model - if (model.includes('/')) { - return model.replace('/', ':'); - } - - // Infer provider from model name - if (model.startsWith('claude') || model.includes('anthropic')) return `anthropic:${model}`; - if (model.startsWith('gpt') || model.startsWith('o1') || model.startsWith('o3') || model.startsWith('o4')) return `openai:${model}`; - if (model.startsWith('gemini')) return `google:${model}`; - if (model.startsWith('deepseek')) return `deepseek:${model}`; - if (model.startsWith('llama') || model.startsWith('mistral')) return `ollama:${model}`; - return `openai:${model}`; + const { provider, modelId } = parseModel(model); + return `${provider}:${modelId}`; } function collectToolNames(agentDir: string): string[] { diff --git a/src/adapters/github.ts b/src/adapters/github.ts index 3d88c9d..090f953 100644 --- a/src/adapters/github.ts +++ b/src/adapters/github.ts @@ -1,5 +1,6 @@ import { resolve, join } from 'node:path'; import { loadAgentManifest } from '../utils/loader.js'; +import { parseModel } from '../utils/model.js'; import { exportToSystemPrompt } from './system-prompt.js'; export interface GitHubModelsPayload { @@ -14,19 +15,10 @@ export interface GitHubModelsPayload { * Map an agent.yaml model to a GitHub Models model ID (vendor/model). */ function resolveModel(model?: string): string { + // Canonical "provider:model" → GitHub Models "provider/model" form. if (!model) return 'openai/gpt-4.1'; - if (model.includes('/')) return model; - - if (model.startsWith('gpt') || model.startsWith('o1') || model.startsWith('o3') || model.startsWith('o4')) { - return `openai/${model}`; - } - if (model.startsWith('claude')) return `anthropic/${model}`; - if (model.startsWith('llama') || model.startsWith('Llama')) return `meta/${model}`; - if (model.startsWith('mistral') || model.startsWith('Mistral')) return `mistralai/${model}`; - if (model.startsWith('gemini')) return `google/${model}`; - if (model.startsWith('deepseek') || model.startsWith('DeepSeek')) return `deepseek/${model}`; - - return model; + const { provider, modelId } = parseModel(model); + return `${provider}/${modelId}`; } /** diff --git a/src/adapters/kiro.ts b/src/adapters/kiro.ts index dcffe47..2e0e7fe 100644 --- a/src/adapters/kiro.ts +++ b/src/adapters/kiro.ts @@ -3,6 +3,7 @@ import { join, resolve } from 'node:path'; import yaml from 'js-yaml'; import { loadAgentManifest, loadFileIfExists } from '../utils/loader.js'; import { loadAllSkills, getAllowedTools } from '../utils/skill-loader.js'; +import { parseModel } from '../utils/model.js'; import { buildComplianceSection } from './shared.js'; /** @@ -168,7 +169,7 @@ function buildConfig( config.prompt = 'file://./prompt.md'; if (manifest.model?.preferred) { - config.model = manifest.model.preferred; + config.model = parseModel(manifest.model.preferred).modelId; } // Collect tools from skills and tool definitions diff --git a/src/adapters/lyzr.ts b/src/adapters/lyzr.ts index cf93f6c..c7e1a14 100644 --- a/src/adapters/lyzr.ts +++ b/src/adapters/lyzr.ts @@ -1,6 +1,7 @@ import { resolve, join } from 'node:path'; import { loadAgentManifest, loadFileIfExists } from '../utils/loader.js'; import { loadAllSkills, getAllowedTools } from '../utils/skill-loader.js'; +import { parseModel } from '../utils/model.js'; import { buildMcpServersMarkdown } from './shared.js'; export interface LyzrAgentPayload { @@ -30,17 +31,14 @@ const PROVIDER_CREDENTIAL_MAP: Record = { function mapModelToLyzrProvider(model?: string): { provider_id: string; model: string } { if (!model) return { provider_id: 'OpenAI', model: 'gpt-4.1' }; - if (model.startsWith('claude')) { - return { provider_id: 'Anthropic', model }; - } - if (model.startsWith('gpt') || model.startsWith('o1') || model.startsWith('o3')) { - return { provider_id: 'OpenAI', model }; - } - if (model.startsWith('gemini')) { - return { provider_id: 'Google', model }; - } - // Default to OpenAI - return { provider_id: 'OpenAI', model }; + // Canonical "provider:model" → Lyzr's capitalized provider_id + bare model id. + const { provider, modelId } = parseModel(model); + const providerMap: Record = { + anthropic: 'Anthropic', + openai: 'OpenAI', + google: 'Google', + }; + return { provider_id: providerMap[provider] ?? 'OpenAI', model: modelId }; } /** diff --git a/src/adapters/nanobot.ts b/src/adapters/nanobot.ts index 81b7a91..a79c282 100644 --- a/src/adapters/nanobot.ts +++ b/src/adapters/nanobot.ts @@ -3,6 +3,7 @@ import { join, resolve } from 'node:path'; import yaml from 'js-yaml'; import { loadAgentManifest, loadFileIfExists } from '../utils/loader.js'; import { loadAllSkills, getAllowedTools } from '../utils/skill-loader.js'; +import { parseModel } from '../utils/model.js'; import { buildMcpServersMarkdown } from './shared.js'; /** @@ -45,8 +46,9 @@ export function exportToNanobotString(dir: string): string { } function buildNanobotConfig(manifest: ReturnType): object { - const model = mapModelName(manifest.model?.preferred ?? 'anthropic/claude-sonnet-4-5-20250929'); - const provider = model.split('/')[0] ?? 'anthropic'; + // Canonical "provider:model" → Nanobot "provider/model" form. + const { provider, modelId } = parseModel(manifest.model?.preferred ?? 'anthropic:claude-sonnet-4-5-20250929'); + const model = `${provider}/${modelId}`; const config: Record = { providers: { @@ -64,23 +66,6 @@ function buildNanobotConfig(manifest: ReturnType): obj return config; } -/** - * Map gitagent model names to Nanobot provider/model format. - * Nanobot uses "anthropic/claude-opus-4-5" style names (via OpenRouter or direct). - */ -function mapModelName(model: string): string { - if (model.includes('/')) { - return model; - } - if (model.startsWith('claude-')) { - return `anthropic/${model}`; - } - if (model.startsWith('gpt-') || model.startsWith('o1') || model.startsWith('o3')) { - return `openai/${model}`; - } - return model; -} - function buildSystemPrompt(agentDir: string, manifest: ReturnType): string { const parts: string[] = []; diff --git a/src/adapters/openai.ts b/src/adapters/openai.ts index c98f67d..53b44e2 100644 --- a/src/adapters/openai.ts +++ b/src/adapters/openai.ts @@ -3,6 +3,7 @@ import { join, resolve } from 'node:path'; import yaml from 'js-yaml'; import { loadAgentManifest, loadFileIfExists } from '../utils/loader.js'; import { loadAllSkills, getAllowedTools } from '../utils/skill-loader.js'; +import { parseModel } from '../utils/model.js'; import { buildMcpServersMarkdown } from './shared.js'; export function exportToOpenAI(dir: string): string { @@ -42,7 +43,7 @@ export function exportToOpenAI(dir: string): string { lines.push(` instructions="""${systemPrompt.replace(/"""/g, '\\"\\"\\"')}""",`); if (manifest.model?.preferred) { - lines.push(` model="${manifest.model.preferred}",`); + lines.push(` model="${parseModel(manifest.model.preferred).modelId}",`); } if (tools.length > 0) { diff --git a/src/adapters/openclaw.ts b/src/adapters/openclaw.ts index 0bf16e9..373bef5 100644 --- a/src/adapters/openclaw.ts +++ b/src/adapters/openclaw.ts @@ -3,6 +3,7 @@ import { join, resolve } from 'node:path'; import yaml from 'js-yaml'; import { loadAgentManifest, loadFileIfExists } from '../utils/loader.js'; import { loadAllSkills, getAllowedTools } from '../utils/skill-loader.js'; +import { parseModel } from '../utils/model.js'; import { buildMcpServersMarkdown } from './shared.js'; /** @@ -112,7 +113,7 @@ export function exportToOpenClawString(dir: string): string { } function buildOpenClawConfig(agentDir: string, manifest: ReturnType): object { - const mainModel = mapModelName(manifest.model?.preferred ?? 'anthropic/claude-sonnet-4-5-20250929'); + const mainModel = mapModelName(manifest.model?.preferred ?? 'anthropic:claude-sonnet-4-5-20250929'); // Check for sub-agents → multi-agent config if (manifest.agents && Object.keys(manifest.agents).length > 0) { @@ -174,20 +175,12 @@ function buildAgentConfig( } /** - * Map gitagent model names to OpenClaw provider/model format. + * Map a canonical "provider:model" name to OpenClaw "provider/model" format. * OpenClaw uses "anthropic/claude-opus-4-6" style names. */ function mapModelName(model: string): string { - if (model.startsWith('anthropic/') || model.startsWith('openai/')) { - return model; - } - if (model.startsWith('claude-')) { - return `anthropic/${model}`; - } - if (model.startsWith('gpt-') || model.startsWith('o1') || model.startsWith('o3')) { - return `openai/${model}`; - } - return model; + const { provider, modelId } = parseModel(model); + return `${provider}/${modelId}`; } function buildAgentsMd(agentDir: string, manifest: ReturnType): string { diff --git a/src/adapters/opencode.ts b/src/adapters/opencode.ts index 43d6b37..1fe0511 100644 --- a/src/adapters/opencode.ts +++ b/src/adapters/opencode.ts @@ -3,6 +3,7 @@ import { join, resolve } from 'node:path'; import yaml from 'js-yaml'; import { loadAgentManifest, loadFileIfExists } from '../utils/loader.js'; import { loadAllSkills, getAllowedTools } from '../utils/skill-loader.js'; +import { parseModel } from '../utils/model.js'; import { buildComplianceSection, buildMcpServersConfig } from './shared.js'; /** @@ -174,11 +175,10 @@ function buildInstructions( function buildConfig(manifest: ReturnType): Record { const config: Record = {}; - // Map model preference to OpenCode provider/model config + // Canonical "provider:model" → OpenCode "provider/model" config if (manifest.model?.preferred) { - const model = manifest.model.preferred; - const provider = inferProvider(model); - config.model = `${provider}/${model}`; + const { provider, modelId } = parseModel(manifest.model.preferred); + config.model = `${provider}/${modelId}`; config.provider = { [provider]: { npm: getNpmPackage(provider), @@ -195,15 +195,6 @@ function buildConfig(manifest: ReturnType): Record = { anthropic: '@ai-sdk/anthropic', diff --git a/src/commands/import.ts b/src/commands/import.ts index ea23704..a818ed4 100644 --- a/src/commands/import.ts +++ b/src/commands/import.ts @@ -28,7 +28,7 @@ function importFromClaude(sourcePath: string, targetDir: string): void { name: dirName.toLowerCase().replace(/[^a-z0-9-]/g, '-'), version: '0.1.0', description: `Imported from Claude Code project: ${dirName}`, - model: { preferred: 'claude-sonnet-4-5-20250929' }, + model: { preferred: 'anthropic:claude-sonnet-4-5-20250929' }, skills: [] as string[], tools: [] as string[], }; @@ -280,8 +280,10 @@ function importFromCodex(sourcePath: string, targetDir: string): void { const dirName = basename(sourceDir); - // codex.json model format: "model-id" (no provider/ prefix, unlike opencode) - const rawModel = (config.model as string) || undefined; + // codex.json model format: "model-id" (no provider prefix) — Codex defaults to openai, + // so emit canonical "openai:model-id". + const rawModelId = (config.model as string) || undefined; + const rawModel = rawModelId ? `openai:${rawModelId}` : undefined; const agentYaml: Record = { spec_version: '0.1.0', name: dirName.toLowerCase().replace(/[^a-z0-9-]/g, '-'), @@ -357,9 +359,9 @@ function importFromOpenCode(sourcePath: string, targetDir: string): void { const dirName = basename(sourceDir); - // Determine model from opencode.json (format: "provider/model-id") + // Determine model from opencode.json (format: "provider/model-id") → canonical "provider:model-id" const rawModel = (config.model as string) || undefined; - const model = rawModel?.includes('/') ? rawModel.split('/').slice(1).join('/') : rawModel; + const model = rawModel?.includes('/') ? rawModel.replace('/', ':') : rawModel; const agentYaml: Record = { spec_version: '0.1.0', name: dirName.toLowerCase().replace(/[^a-z0-9-]/g, '-'), @@ -426,10 +428,15 @@ function importFromGemini(sourcePath: string, targetDir: string): void { const dirName = basename(sourceDir); // Determine model from settings.json (can be string or { id, provider } object) + // → canonical "provider:model-id" const rawModel = settings.model; - const model = typeof rawModel === 'object' && rawModel !== null - ? (rawModel as Record).id - : rawModel as string | undefined; + let model: string | undefined; + if (typeof rawModel === 'object' && rawModel !== null) { + const r = rawModel as Record; + model = r.provider && r.id ? `${r.provider}:${r.id}` : r.id; + } else { + model = rawModel as string | undefined; + } const agentYaml: Record = { spec_version: '0.1.0', name: dirName.toLowerCase().replace(/[^a-z0-9-]/g, '-'), diff --git a/src/commands/registry.ts b/src/commands/registry.ts index 7d6da48..30fbbdb 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -101,7 +101,7 @@ export const registryCommand = new Command('registry') // ── Step 5: Build metadata.json ── const adapters = options.adapters.split(',').map(a => a.trim()).filter(Boolean); const tags = manifest.tags ?? []; - const model = manifest.model?.preferred ?? 'claude-sonnet-4-5-20250929'; + const model = manifest.model?.preferred ?? 'anthropic:claude-sonnet-4-5-20250929'; const license = manifest.license ?? 'MIT'; const metadata = { diff --git a/src/commands/validate.ts b/src/commands/validate.ts index dfcd7a9..a29a3f6 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -7,6 +7,7 @@ const Ajv = _Ajv as unknown as typeof _Ajv.default; const addFormats = _addFormats as unknown as typeof _addFormats.default; import { loadAgentManifest } from '../utils/loader.js'; import { loadSchema } from '../utils/schemas.js'; +import { parseModel, isKnownProvider } from '../utils/model.js'; import { parseSkillMd } from '../utils/skill-loader.js'; import { success, error, warn, info, heading, divider } from '../utils/format.js'; @@ -48,6 +49,22 @@ function validateAgentYaml(dir: string): ValidationResult { result.errors.push(...schemaResult.errors.map(e => `agent.yaml ${e}`)); } + // Check model strings are canonical "provider:model" with a known provider + const models = [manifest.model?.preferred, ...(manifest.model?.fallback ?? [])].filter( + (m): m is string => typeof m === 'string', + ); + for (const m of models) { + try { + const { provider } = parseModel(m); + if (!isKnownProvider(provider)) { + result.warnings.push(`Model "${m}" uses an unknown provider "${provider}"`); + } + } catch (e) { + result.valid = false; + result.errors.push(`agent.yaml model: ${(e as Error).message}`); + } + } + // Check referenced skills exist if (manifest.skills) { for (const skill of manifest.skills) { diff --git a/src/runners/claude.ts b/src/runners/claude.ts index 63be9ec..e32f8ca 100644 --- a/src/runners/claude.ts +++ b/src/runners/claude.ts @@ -7,6 +7,7 @@ import yaml from 'js-yaml'; import { exportToSystemPrompt } from '../adapters/system-prompt.js'; import { AgentManifest } from '../utils/loader.js'; import { loadAllSkills, getAllowedTools } from '../utils/skill-loader.js'; +import { parseModel } from '../utils/model.js'; import { error, info, warn } from '../utils/format.js'; export interface ClaudeRunOptions { @@ -24,14 +25,14 @@ export function runWithClaude(agentDir: string, manifest: AgentManifest, options const args: string[] = []; - // Model + // Model (canonical provider:model → Claude CLI wants the bare model id) if (manifest.model?.preferred) { - args.push('--model', manifest.model.preferred); + args.push('--model', parseModel(manifest.model.preferred).modelId); } // Fallback model if (manifest.model?.fallback?.length) { - args.push('--fallback-model', manifest.model.fallback[0]); + args.push('--fallback-model', parseModel(manifest.model.fallback[0]).modelId); } // Max turns diff --git a/src/runners/gemini.ts b/src/runners/gemini.ts index c2f9aca..a83ed35 100644 --- a/src/runners/gemini.ts +++ b/src/runners/gemini.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { randomBytes } from 'node:crypto'; import { exportToGemini } from '../adapters/gemini.js'; import { AgentManifest } from '../utils/loader.js'; +import { parseModel } from '../utils/model.js'; import { error, info } from '../utils/format.js'; export interface GeminiRunOptions { @@ -61,7 +62,7 @@ export function runWithGemini(agentDir: string, manifest: AgentManifest, options // Model override (if specified in manifest and not in settings) if (manifest.model?.preferred && !exp.settings.model) { - args.push('--model', manifest.model.preferred); + args.push('--model', parseModel(manifest.model.preferred).modelId); } // Approval mode from compliance (if not already in settings) diff --git a/src/runners/git.ts b/src/runners/git.ts index f0fafec..abf0dcd 100644 --- a/src/runners/git.ts +++ b/src/runners/git.ts @@ -2,6 +2,7 @@ import { resolve } from 'node:path'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { AgentManifest, loadAgentManifest, agentDirExists } from '../utils/loader.js'; +import { parseModel } from '../utils/model.js'; import { resolveRepo, ResolveRepoOptions } from '../utils/git-cache.js'; import { exportToSystemPrompt } from '../adapters/system-prompt.js'; import { runWithClaude } from './claude.js'; @@ -152,17 +153,22 @@ function detectAdapter(agentDir: string, manifest: AgentManifest): string { } } - // 2. Model name hints + // 2. Model name hints (canonical "provider:model" → provider implies adapter) const model = manifest.model?.preferred; - if (model) { - if (model.startsWith('claude')) { + if (model && model.includes(':')) { + const { provider } = parseModel(model); + if (provider === 'anthropic') { info('Auto-detected adapter: claude (from model preference)'); return 'claude'; } - if (model.startsWith('gpt') || model.startsWith('o1') || model.startsWith('o3')) { + if (provider === 'openai') { info('Auto-detected adapter: openai (from model preference)'); return 'openai'; } + if (provider === 'google') { + info('Auto-detected adapter: gemini (from model preference)'); + return 'gemini'; + } } // 3. Framework-specific file hints diff --git a/src/runners/github.ts b/src/runners/github.ts index 44a5037..5350445 100644 --- a/src/runners/github.ts +++ b/src/runners/github.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { exportToSystemPrompt } from '../adapters/system-prompt.js'; import { AgentManifest } from '../utils/loader.js'; +import { parseModel } from '../utils/model.js'; import { error, info, success, label, heading, divider } from '../utils/format.js'; import { ensureGitHubAuth } from '../utils/auth-provision.js'; @@ -26,36 +27,10 @@ export interface GitHubRunOptions { * prefix the most likely vendor namespace. */ function resolveGitHubModel(model?: string): string { + // Canonical "provider:model" → GitHub Models "provider/model" namespace. if (!model) return DEFAULT_MODEL; - - // Already namespaced (e.g. "openai/gpt-4.1") - if (model.includes('/')) return model; - - // Map common model prefixes to GitHub Models namespaces - if (model.startsWith('gpt') || model.startsWith('o1') || model.startsWith('o3') || model.startsWith('o4')) { - return `openai/${model}`; - } - if (model.startsWith('claude')) { - return `anthropic/${model}`; - } - if (model.startsWith('llama') || model.startsWith('Llama')) { - return `meta/${model}`; - } - if (model.startsWith('mistral') || model.startsWith('Mistral')) { - return `mistralai/${model}`; - } - if (model.startsWith('gemini')) { - return `google/${model}`; - } - if (model.startsWith('deepseek') || model.startsWith('DeepSeek')) { - return `deepseek/${model}`; - } - if (model.startsWith('cohere')) { - return `cohere/${model}`; - } - - // Fall back — let GitHub Models resolve it - return model; + const { provider, modelId } = parseModel(model); + return `${provider}/${modelId}`; } /** diff --git a/src/utils/model.ts b/src/utils/model.ts new file mode 100644 index 0000000..84b1ff4 --- /dev/null +++ b/src/utils/model.ts @@ -0,0 +1,72 @@ +/** + * Canonical model-string handling for gitagent manifests. + * + * The canonical format is "provider:model-id" (colon-separated), e.g. + * anthropic:claude-opus-4-8 + * openai:gpt-4o + * openai:gpt-4o@https://my-host:8080 (custom OpenAI-compatible endpoint) + * + * Parsing splits on the FIRST colon only: everything before is the provider, + * everything after is the model id (so an "@host:port" suffix stays intact). + */ + +/** Providers recognised by the gitagent ecosystem. */ +export const KNOWN_PROVIDERS = [ + 'amazon-bedrock', + 'anthropic', + 'google', + 'google-gemini-cli', + 'google-antigravity', + 'google-vertex', + 'openai', + 'azure-openai-responses', + 'openai-codex', + 'deepseek', + 'github-copilot', + 'xai', + 'groq', + 'cerebras', + 'openrouter', + 'vercel-ai-gateway', + 'zai', + 'mistral', + 'minimax', + 'minimax-cn', + 'huggingface', + 'fireworks', + 'opencode', + 'opencode-go', + 'kimi-coding', + 'cloudflare-workers-ai', +] as const; + +export type KnownProvider = (typeof KNOWN_PROVIDERS)[number]; + +export interface ParsedModel { + provider: string; + modelId: string; +} + +/** + * Parse a canonical "provider:model" string. Splits on the first colon, so any + * "@https://host:port" suffix remains part of modelId. + * + * @throws if the string has no colon (i.e. no provider prefix). + */ +export function parseModel(modelStr: string): ParsedModel { + const colonIndex = modelStr.indexOf(':'); + if (colonIndex === -1) { + throw new Error( + `Invalid model format: "${modelStr}". Expected "provider:model" (e.g. "anthropic:claude-opus-4-8").`, + ); + } + return { + provider: modelStr.slice(0, colonIndex), + modelId: modelStr.slice(colonIndex + 1), + }; +} + +/** Whether a provider string is in the known-providers list. */ +export function isKnownProvider(provider: string): provider is KnownProvider { + return (KNOWN_PROVIDERS as readonly string[]).includes(provider); +}