From 1b9d9d085123e6baa9cd02691acca6a7c78a9579 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 10 Jun 2026 04:05:19 +0200 Subject: [PATCH] refactor(catalog)!: split model catalog from pi-ai Move bundled models, model cache/manager, thinking metadata, effort helpers, provider descriptors/discovery, wire constants, and model identity utilities into the new @oh-my-pi/pi-catalog package. Update pi-ai to keep provider runtime/auth concerns, move catalog provider metadata into CATALOG_PROVIDERS, and migrate coding-agent, agent, stats, docs, and tests to import catalog values from pi-catalog. Split coding-agent model registry helpers into discovery, roles, and models config modules while preserving registry orchestration. BREAKING CHANGE: @oh-my-pi/pi-ai no longer exports catalog subpaths such as /models, /model-cache, /model-manager, /model-thinking, /effort, /provider-models*, discovery helpers, and provider wire constants; use the matching @oh-my-pi/pi-catalog subpaths instead. --- AGENTS.md | 15 +- biome.json | 2 +- bun.lock | 22 +- docs/adding-a-provider.md | 73 +- package.json | 3 +- packages/agent/CHANGELOG.md | 1 + packages/agent/package.json | 1 + packages/agent/src/agent.ts | 2 +- packages/agent/src/compaction/compaction.ts | 2 +- packages/agent/src/compaction/openai.ts | 12 +- packages/agent/src/proxy.ts | 2 +- .../test/compaction-error-status.test.ts | 2 +- .../test/compaction-thinking-level.test.ts | 2 +- packages/agent/test/handoff.test.ts | 2 +- packages/agent/test/harmony-leak.test.ts | 2 +- packages/ai/CHANGELOG.md | 7 + packages/ai/package.json | 28 +- packages/ai/src/auth-gateway/http.ts | 2 +- packages/ai/src/auth-gateway/server.ts | 3 +- packages/ai/src/auth-gateway/types.ts | 2 +- packages/ai/src/index.ts | 8 - .../ai/src/provider-models/descriptors.ts | 43 - packages/ai/src/providers/amazon-bedrock.ts | 6 +- packages/ai/src/providers/anthropic.ts | 25 +- packages/ai/src/providers/cursor.ts | 60 +- .../src/providers/github-copilot-headers.ts | 2 +- .../ai/src/providers/google-gemini-cli.ts | 10 +- packages/ai/src/providers/google-shared.ts | 2 +- .../src/providers/openai-codex-responses.ts | 9 +- .../openai-codex/request-transformer.ts | 4 +- .../openai-codex/response-handler.ts | 2 +- .../ai/src/providers/openai-completions.ts | 12 +- .../src/providers/openai-responses-shared.ts | 2 +- packages/ai/src/providers/openai-responses.ts | 2 +- packages/ai/src/registry/aimlapi.ts | 8 +- .../ai/src/registry/alibaba-coding-plan.ts | 7 +- packages/ai/src/registry/amazon-bedrock.ts | 1 - packages/ai/src/registry/anthropic.ts | 5 +- packages/ai/src/registry/cerebras.ts | 7 +- .../ai/src/registry/cloudflare-ai-gateway.ts | 7 +- packages/ai/src/registry/cursor.ts | 7 +- packages/ai/src/registry/deepseek.ts | 7 +- packages/ai/src/registry/firepass.ts | 6 +- packages/ai/src/registry/fireworks.ts | 7 +- packages/ai/src/registry/github-copilot.ts | 6 +- packages/ai/src/registry/gitlab-duo.ts | 2 - .../ai/src/registry/google-antigravity.ts | 2 - packages/ai/src/registry/google-gemini-cli.ts | 2 - packages/ai/src/registry/google-vertex.ts | 6 +- packages/ai/src/registry/google.ts | 6 +- packages/ai/src/registry/groq.ts | 6 +- packages/ai/src/registry/huggingface.ts | 8 +- packages/ai/src/registry/kilo.ts | 7 +- packages/ai/src/registry/kimi-code.ts | 6 +- packages/ai/src/registry/litellm.ts | 7 +- packages/ai/src/registry/lm-studio.ts | 7 +- packages/ai/src/registry/minimax-code-cn.ts | 2 - packages/ai/src/registry/minimax-code.ts | 2 - packages/ai/src/registry/minimax.ts | 2 - packages/ai/src/registry/mistral.ts | 6 +- packages/ai/src/registry/moonshot.ts | 7 +- packages/ai/src/registry/nanogpt.ts | 7 +- packages/ai/src/registry/nvidia.ts | 7 +- .../ai/src/registry/oauth/github-copilot.ts | 76 +- .../src/registry/oauth/google-antigravity.ts | 2 +- .../src/registry/oauth/google-gemini-cli.ts | 2 +- packages/ai/src/registry/ollama-cloud.ts | 7 +- packages/ai/src/registry/ollama.ts | 7 +- packages/ai/src/registry/openai-codex.ts | 3 - packages/ai/src/registry/openai.ts | 6 +- packages/ai/src/registry/opencode-go.ts | 6 +- packages/ai/src/registry/opencode-zen.ts | 6 +- packages/ai/src/registry/openrouter.ts | 7 +- packages/ai/src/registry/qianfan.ts | 7 +- packages/ai/src/registry/qwen-portal.ts | 12 +- packages/ai/src/registry/registry.ts | 10 +- packages/ai/src/registry/synthetic.ts | 8 +- packages/ai/src/registry/together.ts | 7 +- packages/ai/src/registry/types.ts | 92 +- packages/ai/src/registry/venice.ts | 7 +- packages/ai/src/registry/vercel-ai-gateway.ts | 7 +- packages/ai/src/registry/vllm.ts | 7 +- packages/ai/src/registry/wafer-pass.ts | 7 +- packages/ai/src/registry/wafer-serverless.ts | 11 +- packages/ai/src/registry/xai-oauth.ts | 12 +- packages/ai/src/registry/xai.ts | 6 +- .../ai/src/registry/xiaomi-token-plan-ams.ts | 7 +- .../ai/src/registry/xiaomi-token-plan-cn.ts | 7 +- .../ai/src/registry/xiaomi-token-plan-sgp.ts | 7 +- packages/ai/src/registry/xiaomi.ts | 7 +- packages/ai/src/registry/zai.ts | 7 +- packages/ai/src/registry/zenmux.ts | 7 +- packages/ai/src/registry/zhipu-coding-plan.ts | 7 +- packages/ai/src/stream.ts | 24 +- packages/ai/src/types.ts | 351 +---- packages/ai/src/usage/claude.ts | 3 +- packages/ai/src/usage/github-copilot.ts | 5 +- packages/ai/src/usage/google-antigravity.ts | 2 +- packages/ai/src/usage/openai-codex.ts | 2 +- packages/ai/src/usage/zai.ts | 3 +- packages/ai/src/utils.ts | 24 - packages/ai/test/abort.test.ts | 2 +- .../anthropic-fable-request-shaping.test.ts | 2 +- .../auth-gateway-openai-responses.test.ts | 2 +- .../ai/test/auth-gateway-pi-native.test.ts | 2 +- packages/ai/test/context-overflow.test.ts | 2 +- packages/ai/test/cursor-exec-handlers.test.ts | 2 +- .../test/deepseek-reasoning-content.test.ts | 2 +- packages/ai/test/firepass.live.ts | 3 +- packages/ai/test/firepass.test.ts | 2 +- .../github-copilot-anthropic-auth.test.ts | 2 +- .../ai/test/github-copilot-headers.test.ts | 2 +- .../github-copilot-openai-base-url.test.ts | 2 +- .../ai/test/github-copilot-reasoning.test.ts | 4 +- .../google-gemini-cli-3x-thinking.test.ts | 2 +- packages/ai/test/google-tool-choice.test.ts | 2 +- packages/ai/test/handoff.test.ts | 2 +- packages/ai/test/helpers/index.ts | 2 +- packages/ai/test/image-limits.test.ts | 2 +- packages/ai/test/image-tool-result.test.ts | 3 +- packages/ai/test/issue-1203-repro.test.ts | 2 +- packages/ai/test/issue-1207-repro.test.ts | 4 +- packages/ai/test/issue-1227-repro.test.ts | 2 +- packages/ai/test/issue-1373-repro.test.ts | 2 +- packages/ai/test/issue-1417-repro.test.ts | 4 +- packages/ai/test/issue-1776-repro.test.ts | 2 +- packages/ai/test/issue-1838-repro.test.ts | 2 +- packages/ai/test/issue-2080-repro.test.ts | 2 +- packages/ai/test/issue-2123-repro.test.ts | 2 +- packages/ai/test/issue-826-repro.test.ts | 2 +- packages/ai/test/issue-827-repro.test.ts | 2 +- packages/ai/test/issue-883-repro.test.ts | 2 +- packages/ai/test/issue-911-repro.test.ts | 2 +- packages/ai/test/issue-945-repro.test.ts | 2 +- packages/ai/test/issue-955-repro.test.ts | 2 +- packages/ai/test/issue-959-repro.test.ts | 2 +- .../ai/test/issue-967-vision-guard.test.ts | 2 +- packages/ai/test/issue-969-repro.test.ts | 4 +- packages/ai/test/model-cache.test.ts | 2 +- packages/ai/test/models-cost.test.ts | 2 +- .../models-json-no-local-endpoints.test.ts | 4 +- packages/ai/test/openai-codex-stream.test.ts | 2 +- .../ai/test/openai-completions-compat.test.ts | 4 +- ...enai-completions-disable-reasoning.test.ts | 2 +- .../openai-completions-progress-chunk.test.ts | 2 +- ...nai-completions-tool-result-images.test.ts | 4 +- ...enai-completions-upstream-provider.test.ts | 2 +- .../test/openai-first-event-timeout.test.ts | 2 +- .../test/openai-max-output-tokens-cap.test.ts | 2 +- .../openai-responses-cache-affinity.test.ts | 2 +- .../openai-responses-history-payload.test.ts | 2 +- ...i-responses-omit-max-output-tokens.test.ts | 2 +- .../openai-responses-system-prompt.test.ts | 2 +- .../ai/test/openai-tool-strict-mode.test.ts | 2 +- .../ai/test/provider-fetch-override.test.ts | 2 +- packages/ai/test/provider-registry.test.ts | 28 +- packages/ai/test/provider-response.test.ts | 2 +- packages/ai/test/raw-sse-sdk-capture.test.ts | 2 +- .../ai/test/stream-markup-healing.test.ts | 2 +- packages/ai/test/stream.test.ts | 2 +- packages/ai/test/tokens.test.ts | 2 +- .../ai/test/tool-call-without-result.test.ts | 2 +- packages/ai/test/total-tokens.test.ts | 2 +- packages/ai/test/unicode-surrogate.test.ts | 2 +- packages/ai/test/wafer.live.ts | 3 +- .../ai/test/xai-oauth-effort-strip.test.ts | 4 +- packages/ai/test/xhigh.test.ts | 2 +- .../test/xiaomi-tp-login-integration.test.ts | 2 +- packages/catalog/CHANGELOG.md | 19 + packages/catalog/package.json | 99 ++ .../scripts/generate-models.ts | 21 +- .../src/compat/openai.ts} | 0 .../src}/discovery/antigravity.ts | 6 +- .../utils => catalog/src}/discovery/codex.ts | 6 +- .../src/discovery/cursor-gen}/agent_pb.ts | 0 .../utils => catalog/src}/discovery/cursor.ts | 6 +- .../utils => catalog/src}/discovery/gemini.ts | 6 +- .../utils => catalog/src}/discovery/index.ts | 0 .../src}/discovery/openai-compatible.ts | 4 +- packages/{ai => catalog}/src/effort.ts | 0 .../src}/fireworks-model-id.ts | 0 packages/catalog/src/identity/bundled.ts | 38 + packages/catalog/src/identity/classify.ts | 141 ++ .../src/identity/equivalence.ts} | 75 +- .../src/identity/id.ts} | 0 packages/catalog/src/identity/index.ts | 8 + packages/catalog/src/identity/markers.ts | 49 + .../src/identity/priority.ts} | 0 packages/catalog/src/identity/reference.ts | 134 ++ packages/catalog/src/identity/selection.ts | 65 + packages/catalog/src/index.ts | 15 + packages/{ai => catalog}/src/model-cache.ts | 0 packages/{ai => catalog}/src/model-manager.ts | 0 .../{ai => catalog}/src/model-thinking.ts | 155 +-- packages/{ai => catalog}/src/models.json | 270 +++- packages/{ai => catalog}/src/models.json.d.ts | 0 packages/{ai => catalog}/src/models.ts | 0 .../src/provider-models/bundled-references.ts | 0 .../src/provider-models/descriptor-types.ts | 79 ++ .../src/provider-models/descriptors.ts | 456 +++++++ .../provider-models/discovery-constants.ts | 0 .../src/provider-models/google.ts | 4 +- .../src/provider-models/index.ts | 1 + .../src/provider-models/ollama.ts | 0 .../src/provider-models/openai-compat.ts | 16 +- .../src/provider-models/special.ts | 4 +- packages/catalog/src/types.ts | 330 +++++ packages/catalog/src/utils.ts | 27 + .../src/wire/codex.ts} | 0 .../src/wire/gemini-headers.ts} | 0 packages/catalog/src/wire/github-copilot.ts | 72 + packages/catalog/test/descriptors.test.ts | 27 + .../test/github-copilot-model-limits.test.ts | 8 +- .../test/github-copilot-wire.test.ts} | 2 +- .../test/google-vertex-discovery.test.ts | 9 +- .../test/issue-1617-repro.test.ts | 4 +- .../test/issue-1846-repro.test.ts | 5 +- .../test/issue-1849-repro.test.ts | 4 +- .../test/issue-2105-repro.test.ts | 9 +- .../test/issue-2113-repro.test.ts | 9 +- .../test/issue-772-repro.test.ts | 4 +- .../test/issue-830-repro.test.ts | 6 +- .../test/issue-847-repro.test.ts | 4 +- .../test/issue-887-repro.test.ts | 2 +- .../test/model-id-affixes.test.ts | 2 +- .../test/model-provider-priority.test.ts | 2 +- .../test/model-thinking.test.ts | 6 +- .../test/nanogpt-model-limits.test.ts | 4 +- .../test/ollama-cloud-provider.test.ts | 5 +- .../test/ollama-provider.test.ts | 7 +- packages/{ai => catalog}/test/wafer.test.ts | 11 +- .../test/xai-oauth-bundle.test.ts | 9 +- .../test/zenmux-provider.test.ts | 6 +- .../{ai => catalog}/test/zhipu-compat.test.ts | 6 +- packages/catalog/tsconfig.json | 4 + packages/catalog/tsconfig.publish.json | 12 + packages/coding-agent/CHANGELOG.md | 7 + packages/coding-agent/package.json | 1 + packages/coding-agent/src/cli/args.ts | 2 +- .../coding-agent/src/cli/auth-gateway-cli.ts | 4 +- .../coding-agent/src/cli/dry-balance-cli.ts | 2 +- packages/coding-agent/src/cli/list-models.ts | 3 +- .../coding-agent/src/commands/complete.ts | 2 +- packages/coding-agent/src/commands/launch.ts | 2 +- .../src/commit/model-selection.ts | 2 +- .../src/config/model-discovery.ts | 553 ++++++++ .../coding-agent/src/config/model-registry.ts | 1184 +++-------------- .../coding-agent/src/config/model-resolver.ts | 253 ++-- .../coding-agent/src/config/model-roles.ts | 74 ++ .../coding-agent/src/config/models-config.ts | 129 ++ packages/coding-agent/src/config/settings.ts | 2 +- .../src/eval/completion-bridge.ts | 3 +- packages/coding-agent/src/lib/xai-http.ts | 2 +- packages/coding-agent/src/main.ts | 3 +- packages/coding-agent/src/memories/index.ts | 3 +- .../src/modes/components/model-selector.ts | 6 +- .../src/modes/controllers/input-controller.ts | 2 +- .../modes/controllers/selector-controller.ts | 2 +- .../src/modes/interactive-mode.ts | 12 +- packages/coding-agent/src/sdk.ts | 7 +- .../coding-agent/src/session/agent-session.ts | 7 +- packages/coding-agent/src/thinking.ts | 3 +- packages/coding-agent/src/tools/image-gen.ts | 12 +- .../src/web/search/providers/codex.ts | 3 +- .../src/web/search/providers/gemini.ts | 5 +- .../test/agent-session-acp-permission.test.ts | 2 +- ...gent-session-auto-compaction-queue.test.ts | 2 +- .../test/agent-session-bash-detach.test.ts | 2 +- ...ion-before-agent-start-attribution.test.ts | 3 +- .../test/agent-session-branching.test.ts | 2 +- .../test/agent-session-compaction.test.ts | 2 +- .../test/agent-session-concurrent.test.ts | 3 +- .../test/agent-session-eager-todo.test.ts | 3 +- .../agent-session-force-tool-choice.test.ts | 2 +- .../test/agent-session-handoff.test.ts | 2 +- .../test/agent-session-manual-retry.test.ts | 3 +- .../agent-session-model-persistence.test.ts | 3 +- ...nt-session-openai-responses-replay.test.ts | 2 +- .../test/agent-session-python-cleanup.test.ts | 2 +- .../agent-session-resolve-reminder.test.ts | 2 +- .../test/agent-session-retry-cap.test.ts | 3 +- .../test/agent-session-retry-fallback.test.ts | 4 +- .../test/agent-session-role-thinking.test.ts | 3 +- .../test/agent-session-silent-abort.test.ts | 2 +- .../test/agent-session-skill-keywords.test.ts | 3 +- .../agent-session-user-shortcut-hooks.test.ts | 2 +- .../test/auto-thinking-classifier.test.ts | 3 +- .../test/commit-agentic-attribution.test.ts | 2 +- ...mmit-model-selection-role-thinking.test.ts | 3 +- .../test/compaction-hooks.test.ts | 2 +- .../compaction-prefer-current-model.test.ts | 2 +- packages/coding-agent/test/compaction.test.ts | 2 +- .../edit-auto-generated-regressions.test.ts | 3 +- .../test/input-controller-skill-queue.test.ts | 2 +- .../coding-agent/test/issue-775-repro.test.ts | 3 +- ...issue-986-compaction-auth-fallback.test.ts | 2 +- .../keybindings-escape-components.test.ts | 2 +- .../coding-agent/test/model-discovery.test.ts | 610 +++++++++ .../coding-agent/test/model-registry.test.ts | 557 +------- ...model-selector-role-badge-thinking.test.ts | 3 +- packages/coding-agent/test/role-info.test.ts | 2 +- .../role-thinking-helper-propagation.test.ts | 3 +- .../test/sdk-mcp-discovery.test.ts | 3 +- .../test/sdk-model-selection.test.ts | 2 +- .../coding-agent/test/sdk-move-cwd.test.ts | 2 +- .../test/sdk-session-isolation.test.ts | 3 +- .../test/sdk-tool-activation.test.ts | 2 +- .../test/session-manager-close-race.test.ts | 2 +- .../session/emit-listener-isolation.test.ts | 2 +- packages/coding-agent/test/shake.test.ts | 2 +- .../test/streaming-edit-abort.test.ts | 3 +- .../test/tiny-title-generator.test.ts | 3 +- .../coding-agent/test/title-generator.test.ts | 3 +- .../test/tools/approval-mode.test.ts | 2 +- packages/coding-agent/test/utilities.ts | 2 +- packages/stats/CHANGELOG.md | 1 + packages/stats/package.json | 1 + packages/stats/src/db.ts | 4 +- scripts/ci-release-publish.ts | 1 + scripts/install-tests/run-ci.sh | 6 +- 320 files changed, 4155 insertions(+), 3194 deletions(-) delete mode 100644 packages/ai/src/provider-models/descriptors.ts create mode 100644 packages/catalog/CHANGELOG.md create mode 100644 packages/catalog/package.json rename packages/{ai => catalog}/scripts/generate-models.ts (95%) rename packages/{ai/src/providers/openai-completions-compat.ts => catalog/src/compat/openai.ts} (100%) rename packages/{ai/src/utils => catalog/src}/discovery/antigravity.ts (97%) rename packages/{ai/src/utils => catalog/src}/discovery/codex.ts (98%) rename packages/{ai/src/providers/cursor/gen => catalog/src/discovery/cursor-gen}/agent_pb.ts (100%) rename packages/{ai/src/utils => catalog/src}/discovery/cursor.ts (98%) rename packages/{ai/src/utils => catalog/src}/discovery/gemini.ts (97%) rename packages/{ai/src/utils => catalog/src}/discovery/index.ts (100%) rename packages/{ai/src/utils => catalog/src}/discovery/openai-compatible.ts (97%) rename packages/{ai => catalog}/src/effort.ts (100%) rename packages/{ai/src/utils => catalog/src}/fireworks-model-id.ts (100%) create mode 100644 packages/catalog/src/identity/bundled.ts create mode 100644 packages/catalog/src/identity/classify.ts rename packages/{coding-agent/src/config/model-equivalence.ts => catalog/src/identity/equivalence.ts} (93%) rename packages/{coding-agent/src/config/model-id-affixes.ts => catalog/src/identity/id.ts} (100%) create mode 100644 packages/catalog/src/identity/index.ts create mode 100644 packages/catalog/src/identity/markers.ts rename packages/{coding-agent/src/config/model-provider-priority.ts => catalog/src/identity/priority.ts} (100%) create mode 100644 packages/catalog/src/identity/reference.ts create mode 100644 packages/catalog/src/identity/selection.ts create mode 100644 packages/catalog/src/index.ts rename packages/{ai => catalog}/src/model-cache.ts (100%) rename packages/{ai => catalog}/src/model-manager.ts (100%) rename packages/{ai => catalog}/src/model-thinking.ts (84%) rename packages/{ai => catalog}/src/models.json (99%) rename packages/{ai => catalog}/src/models.json.d.ts (100%) rename packages/{ai => catalog}/src/models.ts (100%) rename packages/{ai => catalog}/src/provider-models/bundled-references.ts (100%) create mode 100644 packages/catalog/src/provider-models/descriptor-types.ts create mode 100644 packages/catalog/src/provider-models/descriptors.ts rename packages/{ai => catalog}/src/provider-models/discovery-constants.ts (100%) rename packages/{ai => catalog}/src/provider-models/google.ts (94%) rename packages/{ai => catalog}/src/provider-models/index.ts (79%) rename packages/{ai => catalog}/src/provider-models/ollama.ts (100%) rename packages/{ai => catalog}/src/provider-models/openai-compat.ts (99%) rename packages/{ai => catalog}/src/provider-models/special.ts (93%) create mode 100644 packages/catalog/src/types.ts create mode 100644 packages/catalog/src/utils.ts rename packages/{ai/src/providers/openai-codex/constants.ts => catalog/src/wire/codex.ts} (100%) rename packages/{ai/src/providers/google-gemini-headers.ts => catalog/src/wire/gemini-headers.ts} (100%) create mode 100644 packages/catalog/src/wire/github-copilot.ts create mode 100644 packages/catalog/test/descriptors.test.ts rename packages/{ai => catalog}/test/github-copilot-model-limits.test.ts (96%) rename packages/{ai/test/github-copilot-oauth.test.ts => catalog/test/github-copilot-wire.test.ts} (95%) rename packages/{ai => catalog}/test/google-vertex-discovery.test.ts (91%) rename packages/{ai => catalog}/test/issue-1617-repro.test.ts (97%) rename packages/{ai => catalog}/test/issue-1846-repro.test.ts (94%) rename packages/{ai => catalog}/test/issue-1849-repro.test.ts (95%) rename packages/{ai => catalog}/test/issue-2105-repro.test.ts (93%) rename packages/{ai => catalog}/test/issue-2113-repro.test.ts (94%) rename packages/{ai => catalog}/test/issue-772-repro.test.ts (93%) rename packages/{ai => catalog}/test/issue-830-repro.test.ts (92%) rename packages/{ai => catalog}/test/issue-847-repro.test.ts (96%) rename packages/{ai => catalog}/test/issue-887-repro.test.ts (97%) rename packages/{coding-agent => catalog}/test/model-id-affixes.test.ts (97%) rename packages/{coding-agent => catalog}/test/model-provider-priority.test.ts (86%) rename packages/{ai => catalog}/test/model-thinking.test.ts (98%) rename packages/{ai => catalog}/test/nanogpt-model-limits.test.ts (92%) rename packages/{ai => catalog}/test/ollama-cloud-provider.test.ts (98%) rename packages/{ai => catalog}/test/ollama-provider.test.ts (94%) rename packages/{ai => catalog}/test/wafer.test.ts (97%) rename packages/{ai => catalog}/test/xai-oauth-bundle.test.ts (83%) rename packages/{ai => catalog}/test/zenmux-provider.test.ts (94%) rename packages/{ai => catalog}/test/zhipu-compat.test.ts (96%) create mode 100644 packages/catalog/tsconfig.json create mode 100644 packages/catalog/tsconfig.publish.json create mode 100644 packages/coding-agent/src/config/model-discovery.ts create mode 100644 packages/coding-agent/src/config/model-roles.ts create mode 100644 packages/coding-agent/src/config/models-config.ts create mode 100644 packages/coding-agent/test/model-discovery.test.ts diff --git a/AGENTS.md b/AGENTS.md index 14af3d4f7..600e80619 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ This repo contains multiple packages, but **`packages/coding-agent/`** is the pr | Package | Description | | ----------------------- | ---------------------------------------------------- | | `packages/ai` | Multi-provider LLM client with streaming support | +| `packages/catalog` | Model catalog: bundled models.json, provider descriptors, model identity/classification | | `packages/agent` | Agent runtime with tool calling and state management | | `packages/coding-agent` | Main CLI application (primary focus) | | `packages/tui` | Terminal UI library with differential rendering | @@ -19,6 +20,8 @@ This repo contains multiple packages, but **`packages/coding-agent/`** is the pr | `packages/utils` | Shared utilities (logger, streams, temp files) | | `crates/pi-natives` | Rust crate for performance-critical text/grep ops | +**Catalog import convention**: code in this repo imports catalog *values* (bundled models, model-thinking helpers, identity, descriptors, model manager/cache) from `@oh-my-pi/pi-catalog/` — never via `@oh-my-pi/pi-ai`. The pi-ai barrel re-exports only the model/effort *types* its own signatures use (`Model`, `Api`, `ThinkingConfig`, `Effort`, …); type-only imports of those from `@oh-my-pi/pi-ai` are fine. + ## Code Quality - No `any` unless absolutely necessary. @@ -147,15 +150,15 @@ Manual reader loops only when the protocol requires it (SSE, streaming JSON-RPC) ## Generated Files -**NEVER edit `packages/ai/src/models.json` directly.** It is generated from upstream sources (models.dev, provider catalog discovery, OpenCode docs) by `packages/ai/scripts/generate-models.ts` and the descriptors/resolvers in `packages/ai/src/provider-models/`. Hand-edits get overwritten on the next regen. +**NEVER edit `packages/catalog/src/models.json` directly.** It is generated from upstream sources (models.dev, provider catalog discovery, OpenCode docs) by `packages/catalog/scripts/generate-models.ts` and the descriptors/resolvers in `packages/catalog/src/provider-models/`. Hand-edits get overwritten on the next regen. To change an entry, fix the source: -- **Resolution rules / per-id overrides** → relevant resolver in `packages/ai/src/provider-models/openai-compat.ts` (e.g. `createOpenCodeApiResolution`'s id-override map). -- **Provider descriptors** (filtering, transforms, defaults, headers, compat overrides) → `packages/ai/src/provider-models/descriptors.ts` or the provider-specific descriptor. -- **Generator-level fixups** (premium multipliers, codex pricing fallback, fallback models, post-processing) → `packages/ai/scripts/generate-models.ts`. -- **Thinking metadata / generated policies** → `packages/ai/src/model-thinking.ts` (`applyGeneratedModelPolicies`). +- **Resolution rules / per-id overrides** → relevant resolver in `packages/catalog/src/provider-models/openai-compat.ts` (e.g. `createOpenCodeApiResolution`'s id-override map). +- **Provider catalog entries** (default model, discovery factory/flags) → the `CATALOG_PROVIDERS` table in `packages/catalog/src/provider-models/descriptors.ts`. +- **Generator-level fixups** (premium multipliers, codex pricing fallback, fallback models, post-processing) → `packages/catalog/scripts/generate-models.ts`. +- **Thinking metadata / generated policies** → `packages/catalog/src/model-thinking.ts` (`applyGeneratedModelPolicies`); model-id classification (family/version parsing) lives in `packages/catalog/src/identity/classify.ts`. -Regenerate with `bun --cwd=packages/ai run generate-models` and commit `models.json` alongside the source change. Add a regression test against the **resolver/descriptor**, not the bundled JSON, so it survives upstream metadata shifts. +Regenerate with `bun --cwd=packages/catalog run generate-models` and commit `models.json` alongside the source change. Add a regression test against the **resolver/descriptor**, not the bundled JSON, so it survives upstream metadata shifts. ## Logging diff --git a/biome.json b/biome.json index 6ea073e83..1fbc28920 100644 --- a/biome.json +++ b/biome.json @@ -62,7 +62,7 @@ "!**/test-sessions.ts", "!**/template.generated.ts", "!**/docs-index.generated.ts", - "!**/gen/agent_pb.ts", + "!**/agent_pb.ts", "!.worktrees/**/*", "!.wt/**/*" ] diff --git a/bun.lock b/bun.lock index e390a1970..61738b81b 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "version": "15.10.10", "dependencies": { "@oh-my-pi/pi-ai": "catalog:", + "@oh-my-pi/pi-catalog": "catalog:", "@oh-my-pi/pi-natives": "catalog:", "@oh-my-pi/pi-utils": "catalog:", "@opentelemetry/api": "catalog:", @@ -33,6 +34,7 @@ "version": "15.10.10", "dependencies": { "@bufbuild/protobuf": "catalog:", + "@oh-my-pi/pi-catalog": "catalog:", "@oh-my-pi/pi-utils": "catalog:", "openai": "catalog:", "partial-json": "catalog:", @@ -42,11 +44,24 @@ "@types/bun": "catalog:", }, }, + "packages/catalog": { + "name": "@oh-my-pi/pi-catalog", + "version": "15.10.10", + "dependencies": { + "@bufbuild/protobuf": "catalog:", + "@oh-my-pi/pi-utils": "catalog:", + "zod": "catalog:", + }, + "devDependencies": { + "@oh-my-pi/pi-ai": "catalog:", + "@types/bun": "catalog:", + }, + }, "packages/coding-agent": { "name": "@oh-my-pi/pi-coding-agent", "version": "15.10.10", "bin": { - "omp": "src/cli.ts", + "omp": "dist/cli.js", }, "dependencies": { "@agentclientprotocol/sdk": "catalog:", @@ -56,6 +71,7 @@ "@oh-my-pi/omp-stats": "catalog:", "@oh-my-pi/pi-agent-core": "catalog:", "@oh-my-pi/pi-ai": "catalog:", + "@oh-my-pi/pi-catalog": "catalog:", "@oh-my-pi/pi-mnemopi": "catalog:", "@oh-my-pi/pi-natives": "catalog:", "@oh-my-pi/pi-tui": "catalog:", @@ -132,6 +148,7 @@ }, "dependencies": { "@oh-my-pi/pi-ai": "catalog:", + "@oh-my-pi/pi-catalog": "catalog:", "@oh-my-pi/pi-utils": "catalog:", "@tailwindcss/node": "catalog:", "chart.js": "catalog:", @@ -252,6 +269,7 @@ "@oh-my-pi/omp-stats": "15.10.10", "@oh-my-pi/pi-agent-core": "15.10.10", "@oh-my-pi/pi-ai": "15.10.10", + "@oh-my-pi/pi-catalog": "15.10.10", "@oh-my-pi/pi-coding-agent": "15.10.10", "@oh-my-pi/pi-mnemopi": "15.10.10", "@oh-my-pi/pi-natives": "15.10.10", @@ -641,6 +659,8 @@ "@oh-my-pi/pi-ai": ["@oh-my-pi/pi-ai@workspace:packages/ai"], + "@oh-my-pi/pi-catalog": ["@oh-my-pi/pi-catalog@workspace:packages/catalog"], + "@oh-my-pi/pi-coding-agent": ["@oh-my-pi/pi-coding-agent@workspace:packages/coding-agent"], "@oh-my-pi/pi-mnemopi": ["@oh-my-pi/pi-mnemopi@workspace:packages/mnemopi"], diff --git a/docs/adding-a-provider.md b/docs/adding-a-provider.md index 55bddd39e..0a25edc87 100644 --- a/docs/adding-a-provider.md +++ b/docs/adding-a-provider.md @@ -1,26 +1,42 @@ # Adding a provider -Providers in `packages/ai` are described by a single declarative -`ProviderDefinition` and collected in one registry. Every scattered structure — -the `KnownProvider` / `OAuthProvider` type unions, `PROVIDER_DESCRIPTORS`, -`DEFAULT_MODEL_PER_PROVIDER`, the `serviceProviderMap` env-key fallbacks, the -`/login` provider list, the `refreshOAuthToken` / `AuthStorage.login` dispatch, -and the coding-agent callback maps — is **derived** from that registry. +A provider is described in two halves: + +- **Catalog half** (`packages/catalog`): one entry in the `CATALOG_PROVIDERS` + table (`packages/catalog/src/provider-models/descriptors.ts`) carrying the + `id`, `defaultModel`, runtime model-discovery factory, and catalog-generation + wiring. `KnownProvider`, `PROVIDER_DESCRIPTORS`, and + `DEFAULT_MODEL_PER_PROVIDER` are derived from this table. +- **Auth half** (`packages/ai`): one declarative `ProviderDefinition` in the + registry carrying env-key fallbacks and login/refresh flows. The + `OAuthProvider` union, the env-key map, the `/login` provider list, the + `refreshOAuthToken` / `AuthStorage.login` dispatch, and the coding-agent + callback maps are derived from the registry. **Scope.** This is for a provider that reuses an existing wire API (`openai-completions`, `anthropic-messages`, `google-generative-ai`, …) — the common case for gateways and API-key providers, since stream dispatch keys on `model.api`, not `model.provider`. Adding a *new wire protocol* (a new `KnownApi`) is a separate task that also touches `stream.ts` dispatch, -`api-registry.ts`, and `types.ts`. +`api-registry.ts`, and the catalog `types.ts`. ## Shape -For the common case, a provider is still **one new def file + one registry line**: +For the common case, a provider is **one catalog entry + one def file + one registry line**: -1. **Create `packages/ai/src/registry/.ts`** exporting one - `export const Provider = { … } as const satisfies ProviderDefinition;`. -2. **Add it to the `ALL` array** in `packages/ai/src/registry/registry.ts` +1. **Add an entry to `CATALOG_PROVIDERS`** in + `packages/catalog/src/provider-models/descriptors.ts` with the `id`, + `defaultModel`, the plain API-key env var(s) as `envVars`, and (usually) a + `createModelManagerOptions` factory. For a + simple OpenAI-compatible gateway, build the factory in + `packages/catalog/src/provider-models/openai-compat.ts` or inline with the + exported `createSimpleOpenAICompletionsOptions(providerId, baseUrl, config)`. +2. **Create `packages/ai/src/registry/.ts`** exporting one + `export const Provider = { … } as const satisfies ProviderDefinition;` + with the auth fields (`login`, …). Plain env-var names live in the catalog + entry's `envVars`; set `envKeys` only for computed resolvers (Foundry/ADC/ + Bedrock-style probes). +3. **Add it to the `ALL` array** in `packages/ai/src/registry/registry.ts` (one import + one array entry). `ALL` order is the `/login` list order for loginable providers. @@ -34,26 +50,33 @@ For a **non-trivial provider-local OAuth flow**, put the implementation in file. The shared OAuth flow infrastructure it builds on lives in the same `registry/oauth/` directory. -Either way, descriptors, default-model map, env-key map, login list, and refresh -dispatch all update automatically, and the `KnownProvider` / `OAuthProvider` -unions gain the new id by derivation. +Descriptors, the default-model map, env-key map, login list, and refresh +dispatch all update automatically; the `KnownProvider` union gains the new id +from the catalog table and `OAuthProvider` from the registry. -## `ProviderDefinition` fields +## Field reference -See `packages/ai/src/registry/types.ts` for the authoritative, -JSDoc-annotated interface. Presence of a field opts the provider into a derived -structure: +**Catalog table entry** (`ProviderCatalogEntry`, see +`packages/catalog/src/provider-models/descriptor-types.ts` for JSDoc): + +| Field | Effect | +|---|---| +| `id` | Required. Member of `KnownProvider`. | +| `defaultModel` | Required. Preferred model when no explicit selection is made. | +| `envVars` | Env var name(s), in order, for the runtime API-key fallback (`getEnvApiKey`). | +| `createModelManagerOptions` | Runtime model-discovery factory. Present (and not `specialModelManager`) ⇒ appears in `PROVIDER_DESCRIPTORS`. | +| `allowUnauthenticated` | Runtime creates a model manager even without a key. | +| `dynamicModelsAuthoritative` | Successful discovery replaces bundled models. | +| `catalogDiscovery` | `{ label, envVars?, oauthProvider?, allowUnauthenticated? }` for offline catalog generation (`generate-models.ts`). `envVars` here overrides the entry-level list when generation uses different credentials (e.g. `cursor`). | +| `specialModelManager` | Bespoke runtime factory (`google-antigravity` / `google-gemini-cli` / `openai-codex`); excluded from `PROVIDER_DESCRIPTORS`. | + +**Registry definition** (`ProviderDefinition`, see +`packages/ai/src/registry/types.ts`): | Field | Effect | |---|---| | `id`, `name` | Required. `name` shows in the `/login` list. | -| `defaultModel` | Present ⇒ member of `KnownProvider` (a chat-model provider). | -| `createModelManagerOptions` | Runtime model-discovery factory. Present (and not `specialModelManager`) ⇒ appears in `PROVIDER_DESCRIPTORS`. | -| `allowUnauthenticated` | Runtime creates a model manager even without a key. | -| `dynamicModelsAuthoritative` | Successful discovery replaces bundled models. | -| `catalogDiscovery` | `{ label, envVars, oauthProvider?, allowUnauthenticated? }` for offline catalog generation (`generate-models.ts`). | -| `specialModelManager` | Bespoke runtime factory (`google-antigravity` / `google-gemini-cli` / `openai-codex`); excluded from `PROVIDER_DESCRIPTORS`. | -| `envKeys` | Env-var fallback for `getEnvApiKey`: a var name string or a `() => string \| undefined` resolver. | +| `envKeys` | Computed env fallback for `getEnvApiKey`, overriding the catalog entry's `envVars`: a var name string or a `() => string \| undefined` resolver. Omit when `envVars` covers it. | | `login` | Interactive login. Present ⇒ member of `OAuthProvider`, shown in `/login`, dispatchable via `AuthStorage.login`. Returns an api-key `string` or `OAuthCredentials`. | | `refreshToken` | OAuth refresher; omit for static-token providers (the dispatch returns credentials unchanged). | | `storeCredentialsAs` | Store credentials under a different provider id (e.g. `openai-codex-device` ⇒ `openai-codex`). | diff --git a/package.json b/package.json index 9eb8631a2..144eac32e 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@oh-my-pi/omp-stats": "15.10.10", "@oh-my-pi/pi-agent-core": "15.10.10", "@oh-my-pi/pi-ai": "15.10.10", + "@oh-my-pi/pi-catalog": "15.10.10", "@oh-my-pi/pi-coding-agent": "15.10.10", "@oh-my-pi/pi-mnemopi": "15.10.10", "@oh-my-pi/pi-natives": "15.10.10", @@ -152,7 +153,7 @@ "publish": "bun run prepublishOnly && npm publish -ws --access public", "publish:dry": "bun run prepublishOnly && npm publish -ws --access public --dry-run", "release": "bun scripts/release.ts", - "generate-models": "bun --cwd=packages/ai run generate-models", + "generate-models": "bun --cwd=packages/catalog run generate-models", "generate-docs-index": "bun --cwd=packages/coding-agent run generate-docs-index", "generate-template": "bun --cwd=packages/coding-agent run generate-template", "check-spoofed-versions": "bun scripts/check-spoofed-versions.ts" diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 72583b86e..6553d9fdc 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Changed - Editorial pass over the compaction prompts: fixed garbled grammar and missing articles, RFC-keyed prohibitions, deduped restated instructions; parsed markers (``/``/``) and all output-format headings left byte-identical +- Catalog imports moved to the new `@oh-my-pi/pi-catalog` package: subpath imports (`calculateCost`, Codex wire constants) plus catalog values previously taken from the `@oh-my-pi/pi-ai` root (`getBundledModel`, `clampThinkingLevelForModel`), which pi-ai no longer re-exports; type-only `Model`/`Api`/`Effort` imports from pi-ai are unchanged ## [15.10.8] - 2026-06-09 ### Added diff --git a/packages/agent/package.json b/packages/agent/package.json index c225361c3..305a9806d 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -36,6 +36,7 @@ }, "dependencies": { "@oh-my-pi/pi-ai": "catalog:", + "@oh-my-pi/pi-catalog": "catalog:", "@oh-my-pi/pi-natives": "catalog:", "@oh-my-pi/pi-utils": "catalog:", "@opentelemetry/api": "catalog:" diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 4a339a1c3..5f48c7832 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -9,7 +9,6 @@ import { type CursorExecHandlers, type CursorToolResultHandler, type Effort, - getBundledModel, type ImageContent, type Message, type Model, @@ -22,6 +21,7 @@ import { type ToolChoice, type ToolResultMessage, } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { abortReasonText, agentLoop, agentLoopContinue } from "./agent-loop"; import type { AppendOnlyContextManager } from "./append-only-context"; import type { HarmonyAuditEvent } from "./harmony-leak"; diff --git a/packages/agent/src/compaction/compaction.ts b/packages/agent/src/compaction/compaction.ts index e06aa9fd5..0fb97be23 100644 --- a/packages/agent/src/compaction/compaction.ts +++ b/packages/agent/src/compaction/compaction.ts @@ -7,7 +7,6 @@ import { type AssistantMessage, - clampThinkingLevelForModel, Effort, type FetchImpl, type Message, @@ -15,6 +14,7 @@ import { type Model, type Usage, } from "@oh-my-pi/pi-ai"; +import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking"; import { countTokens } from "@oh-my-pi/pi-natives"; import { logger, prompt } from "@oh-my-pi/pi-utils"; import { type AgentTelemetry, instrumentedCompleteSimple } from "../telemetry"; diff --git a/packages/agent/src/compaction/openai.ts b/packages/agent/src/compaction/openai.ts index 0b6ad71e8..7ff9b7d93 100644 --- a/packages/agent/src/compaction/openai.ts +++ b/packages/agent/src/compaction/openai.ts @@ -12,12 +12,6 @@ * with `{ summary, shortSummary? }`. */ -import { - CODEX_BASE_URL, - getCodexAccountId, - OPENAI_HEADER_VALUES, - OPENAI_HEADERS, -} from "@oh-my-pi/pi-ai/providers/openai-codex/constants"; import { parseTextSignature } from "@oh-my-pi/pi-ai/providers/openai-responses-shared"; import { transformMessages } from "@oh-my-pi/pi-ai/providers/transform-messages"; import type { AssistantMessage, FetchImpl, Message, Model } from "@oh-my-pi/pi-ai/types"; @@ -26,6 +20,12 @@ import { getOpenAIResponsesHistoryPayload, normalizeResponsesToolCallId, } from "@oh-my-pi/pi-ai/utils"; +import { + CODEX_BASE_URL, + getCodexAccountId, + OPENAI_HEADER_VALUES, + OPENAI_HEADERS, +} from "@oh-my-pi/pi-catalog/wire/codex"; import { logger } from "@oh-my-pi/pi-utils"; // ============================================================================ diff --git a/packages/agent/src/proxy.ts b/packages/agent/src/proxy.ts index 5bb82ef81..5c609a8db 100644 --- a/packages/agent/src/proxy.ts +++ b/packages/agent/src/proxy.ts @@ -13,8 +13,8 @@ import { type StopReason, type ToolCall, } from "@oh-my-pi/pi-ai"; -import { calculateCost } from "@oh-my-pi/pi-ai/models"; import { parseStreamingJson } from "@oh-my-pi/pi-ai/utils/json-parse"; +import { calculateCost } from "@oh-my-pi/pi-catalog/models"; import { readSseJson } from "@oh-my-pi/pi-utils"; // Event stream adapter for proxy SSE events diff --git a/packages/agent/test/compaction-error-status.test.ts b/packages/agent/test/compaction-error-status.test.ts index b9144b67b..df28ec7e5 100644 --- a/packages/agent/test/compaction-error-status.test.ts +++ b/packages/agent/test/compaction-error-status.test.ts @@ -9,7 +9,7 @@ import { } from "@oh-my-pi/pi-agent-core/compaction"; import type { AssistantMessage, Model } from "@oh-my-pi/pi-ai"; import * as ai from "@oh-my-pi/pi-ai"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; // Pins the fix for the "raw 401 surfaced as Compaction failed:" bug. // diff --git a/packages/agent/test/compaction-thinking-level.test.ts b/packages/agent/test/compaction-thinking-level.test.ts index 5bc61a491..49e177017 100644 --- a/packages/agent/test/compaction-thinking-level.test.ts +++ b/packages/agent/test/compaction-thinking-level.test.ts @@ -10,7 +10,7 @@ import { import { ThinkingLevel } from "@oh-my-pi/pi-agent-core/thinking"; import type { AssistantMessage, Model } from "@oh-my-pi/pi-ai"; import * as ai from "@oh-my-pi/pi-ai"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; // Pins fix #1 of the compaction effort-override bug. Before this fix, // `generateHandoff` (and the three other compaction summarizers) hardcoded diff --git a/packages/agent/test/handoff.test.ts b/packages/agent/test/handoff.test.ts index 2f0affc81..b8b0c9ef4 100644 --- a/packages/agent/test/handoff.test.ts +++ b/packages/agent/test/handoff.test.ts @@ -4,7 +4,7 @@ import { AUTO_HANDOFF_THRESHOLD_FOCUS, generateHandoff, renderHandoffPrompt } fr import type { AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai"; import * as ai from "@oh-my-pi/pi-ai"; import { Effort } from "@oh-my-pi/pi-ai"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; function createAssistantMessage(content: AssistantMessage["content"]): AssistantMessage { return { diff --git a/packages/agent/test/harmony-leak.test.ts b/packages/agent/test/harmony-leak.test.ts index 9588ec570..83f3099af 100644 --- a/packages/agent/test/harmony-leak.test.ts +++ b/packages/agent/test/harmony-leak.test.ts @@ -9,7 +9,7 @@ import { signalListLabel, } from "@oh-my-pi/pi-agent-core/harmony-leak"; import type { AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai"; -import { getBundledModel } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import corpus from "./fixtures/harmony-leak-corpus.json" with { type: "json" }; import { createAssistantMessage } from "./helpers"; diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 2345b43f8..5dfcd1e00 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,11 +2,18 @@ ## [Unreleased] +### Breaking Changes + +- The model catalog moved to the new `@oh-my-pi/pi-catalog` package. Deep subpath exports `@oh-my-pi/pi-ai/models.json`, `/models`, `/model-cache`, `/model-manager`, `/model-thinking`, `/effort`, `/provider-models*`, `/utils/discovery*`, `/providers/openai-codex/constants`, `/providers/google-gemini-headers`, and `/providers/openai-completions-compat` are gone — import the `@oh-my-pi/pi-catalog` equivalents (`/models.json`, `/models`, `/model-cache`, `/model-manager`, `/model-thinking`, `/effort`, `/provider-models*`, `/discovery*`, `/wire/codex`, `/wire/gemini-headers`, `/compat/openai`). The pi-ai root barrel re-exports only the model/effort *types* its own signatures use (`Model`, `Api`, `ThinkingConfig`, `Effort`, `Usage`, compat interfaces) — catalog *values* (`getBundledModel(s)`, `calculateCost`, `modelsAreEqual`, `clampThinkingLevelForModel`, `DEFAULT_MODEL_PER_PROVIDER`, …) must be imported from `@oh-my-pi/pi-catalog`. +- `ProviderDefinition` is now auth-only: `defaultModel`, `createModelManagerOptions`, `catalogDiscovery`, `dynamicModelsAuthoritative`, `allowUnauthenticated`, and `specialModelManager` moved to pi-catalog's `CATALOG_PROVIDERS` table, and `KnownProviderId` was replaced by pi-catalog's `KnownProvider` (registry completeness is enforced by a compile-time check against that union). The pure GitHub Copilot key/endpoint helpers moved from `registry/oauth/github-copilot` to `@oh-my-pi/pi-catalog/wire/github-copilot`. + ### Changed - Reduced idle-watchdog churn on the token hot path: the abort promise/listener is created once per stream instead of per yielded item, the deadline uses a persistent re-armed timer instead of a `setTimeout` create/destroy pair per delta, and the persistent race promises are re-minted every 1024 items so per-race reaction records cannot accumulate for the stream's whole life. - Memoized Anthropic many-image downscaling by content-block identity, so long sessions with stable message objects no longer re-decode and re-encode every oversized image on each request and retry. - Tool-argument validation errors now truncate embedded argument strings at 256 chars per field — a failed `write`-class call no longer echoes hundreds of KB of payload back to the model as the error message. +- Auth storage no longer issues per-boot no-op writes: the schema-version row is only rewritten when the recorded version actually changes, and the credential identity-key backfill skips rows whose derived identity is null — reopening a current-schema database now performs zero write transactions +- Plain provider env-var names moved to the catalog table: registry defs dropped their 48 `envKeys` literals (including the pure `$pickenv` pickers for `huggingface`/`qwen-portal`/`xai-oauth`), `getEnvApiKey` now derives those fallbacks from `CATALOG_PROVIDERS[].envVars`, and `envKeys` remains only for computed resolvers (Anthropic Foundry, Vertex ADC, Bedrock credential chains) and non-catalog providers (`kagi`, `tavily`, `parallel`, `perplexity`) ### Fixed diff --git a/packages/ai/package.json b/packages/ai/package.json index 85015886c..4882b6264 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -34,11 +34,11 @@ "lint": "biome lint .", "test": "bun test --parallel", "fix": "biome check --write --unsafe .", - "fmt": "biome format --write .", - "generate-models": "bun scripts/generate-models.ts" + "fmt": "biome format --write ." }, "dependencies": { "@bufbuild/protobuf": "catalog:", + "@oh-my-pi/pi-catalog": "catalog:", "@oh-my-pi/pi-utils": "catalog:", "openai": "catalog:", "partial-json": "catalog:", @@ -80,26 +80,10 @@ "types": "./src/auth-gateway/*.ts", "import": "./src/auth-gateway/*.ts" }, - "./models.json": { - "types": "./src/models.json.d.ts", - "import": "./src/models.json" - }, - "./provider-models": { - "types": "./src/provider-models/index.ts", - "import": "./src/provider-models/index.ts" - }, - "./provider-models/*": { - "types": "./src/provider-models/*.ts", - "import": "./src/provider-models/*.ts" - }, "./providers/*": { "types": "./src/providers/*.ts", "import": "./src/providers/*.ts" }, - "./providers/cursor/gen/*": { - "types": "./src/providers/cursor/gen/*.ts", - "import": "./src/providers/cursor/gen/*.ts" - }, "./providers/openai-codex/*": { "types": "./src/providers/openai-codex/*.ts", "import": "./src/providers/openai-codex/*.ts" @@ -112,14 +96,6 @@ "types": "./src/utils/*.ts", "import": "./src/utils/*.ts" }, - "./utils/discovery": { - "types": "./src/utils/discovery/index.ts", - "import": "./src/utils/discovery/index.ts" - }, - "./utils/discovery/*": { - "types": "./src/utils/discovery/*.ts", - "import": "./src/utils/discovery/*.ts" - }, "./oauth": { "types": "./src/registry/oauth/index.ts", "import": "./src/registry/oauth/index.ts" diff --git a/packages/ai/src/auth-gateway/http.ts b/packages/ai/src/auth-gateway/http.ts index 3e79e56c0..21ea80d61 100644 --- a/packages/ai/src/auth-gateway/http.ts +++ b/packages/ai/src/auth-gateway/http.ts @@ -74,7 +74,7 @@ const PASSTHROUGH_HEADER_NAMES: Record = { "openai-organization": true, "openai-project": true, "openai-beta": true, - // Codex / ChatGPT-OAuth backend headers (see openai-codex/constants.ts). + // Codex / ChatGPT-OAuth backend headers (see @oh-my-pi/pi-catalog/wire/codex). // `session_id` and `conversation_id` thread the upstream session so prompt // caching and per-conversation rate limiting work; `chatgpt-account-id` and // `originator` identify the calling account and client surface. diff --git a/packages/ai/src/auth-gateway/server.ts b/packages/ai/src/auth-gateway/server.ts index ac333b82e..1de3889c9 100644 --- a/packages/ai/src/auth-gateway/server.ts +++ b/packages/ai/src/auth-gateway/server.ts @@ -17,10 +17,11 @@ * POST /v1/messages → Anthropic messages in/out * POST /v1/responses → OpenAI Responses in/out */ + +import { Effort } from "@oh-my-pi/pi-catalog/effort"; import { extractRetryHint, logger } from "@oh-my-pi/pi-utils"; import type { ApiKeyResolver } from "../auth-retry"; import type { AuthStorage } from "../auth-storage"; -import { Effort } from "../effort"; import * as anthropicMessages from "../providers/anthropic-messages-server"; import * as openaiChat from "../providers/openai-chat-server"; import * as openaiResponses from "../providers/openai-responses-server"; diff --git a/packages/ai/src/auth-gateway/types.ts b/packages/ai/src/auth-gateway/types.ts index bdb563e3b..333205366 100644 --- a/packages/ai/src/auth-gateway/types.ts +++ b/packages/ai/src/auth-gateway/types.ts @@ -1,4 +1,4 @@ -import type { Effort } from "../effort"; +import type { Effort } from "@oh-my-pi/pi-catalog/effort"; import type { AssistantMessage, AssistantMessageEventStream, diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 7209e8145..6ae6ab050 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -5,13 +5,7 @@ export { type AuthGatewayBootOptions, type ModelResolver, startAuthGateway } fro export * from "./auth-gateway/types"; export * from "./auth-retry"; export * from "./auth-storage"; -export * from "./effort"; -export * from "./model-cache"; -export * from "./model-manager"; -export * from "./model-thinking"; -export * from "./models"; export * from "./provider-details"; -export * from "./provider-models"; export * from "./providers/anthropic"; export * from "./providers/anthropic-client"; export * from "./providers/azure-openai-responses"; @@ -19,7 +13,6 @@ export type * from "./providers/cursor"; export * from "./providers/gitlab-duo"; export type * from "./providers/google"; export type * from "./providers/google-gemini-cli"; -export * from "./providers/google-gemini-headers"; export type * from "./providers/google-vertex"; export * from "./providers/kimi"; export * from "./providers/mock"; @@ -42,7 +35,6 @@ export * from "./usage/minimax-code"; export * from "./usage/openai-codex"; export * from "./usage/zai"; export * from "./utils/anthropic-auth"; -export * from "./utils/discovery"; export * from "./utils/event-stream"; export * from "./utils/overflow"; export * from "./utils/retry"; diff --git a/packages/ai/src/provider-models/descriptors.ts b/packages/ai/src/provider-models/descriptors.ts deleted file mode 100644 index 5685eccb9..000000000 --- a/packages/ai/src/provider-models/descriptors.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Provider descriptors and the default-model map, derived from the single-source - * provider registry (`../registry`). - * - * The descriptor/catalog types and guards now live in the registry; they are - * re-exported here for back-compat with `generate-models.ts` and existing - * `@oh-my-pi/pi-ai/provider-models` consumers. - */ -import { PROVIDER_REGISTRY } from "../registry"; -import type { ProviderDescriptor } from "../registry/types"; -import type { KnownProvider } from "../types"; - -export * from "../registry/types"; - -/** - * Runtime model-discovery descriptors: every registry provider that exposes a - * standard model-manager factory. Special-managed providers - * (`google-antigravity`/`google-gemini-cli`/`openai-codex`) are built bespoke in - * the coding-agent runtime and are excluded here. - */ -export const PROVIDER_DESCRIPTORS: readonly ProviderDescriptor[] = PROVIDER_REGISTRY.flatMap(provider => { - const { createModelManagerOptions } = provider; - if (!createModelManagerOptions || provider.specialModelManager) { - return []; - } - return [ - { - providerId: provider.id, - defaultModel: provider.defaultModel ?? "", - createModelManagerOptions, - allowUnauthenticated: provider.allowUnauthenticated, - dynamicModelsAuthoritative: provider.dynamicModelsAuthoritative, - catalogDiscovery: provider.catalogDiscovery, - }, - ]; -}); - -/** Default model IDs for all known providers, derived from the registry. */ -export const DEFAULT_MODEL_PER_PROVIDER: Record = Object.fromEntries( - PROVIDER_REGISTRY.filter(provider => provider.defaultModel != null).map( - provider => [provider.id, provider.defaultModel] as [string, string], - ), -) as Record; diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index 646c623a9..31e8af89d 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -7,10 +7,10 @@ * Bun's native `HTTPS_PROXY` support. */ +import type { Effort } from "@oh-my-pi/pi-catalog/effort"; +import { mapEffortToAnthropicAdaptiveEffort, requireSupportedEffort } from "@oh-my-pi/pi-catalog/model-thinking"; +import { calculateCost } from "@oh-my-pi/pi-catalog/models"; import { $env, $flag, extractHttpStatusFromError, fetchWithRetry } from "@oh-my-pi/pi-utils"; -import type { Effort } from "../effort"; -import { mapEffortToAnthropicAdaptiveEffort, requireSupportedEffort } from "../model-thinking"; -import { calculateCost } from "../models"; import type { Api, AssistantMessage, diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index bfa012011..ce2c4df34 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -2,6 +2,15 @@ import * as nodeCrypto from "node:crypto"; import * as fs from "node:fs"; import { scheduler } from "node:timers/promises"; import * as tls from "node:tls"; +import { + hasOpus47ApiRestrictions, + isAnthropicFableOrMythosModel, + mapEffortToAnthropicAdaptiveEffort, + supportsMidConversationSystemMessages, +} from "@oh-my-pi/pi-catalog/model-thinking"; +import { calculateCost } from "@oh-my-pi/pi-catalog/models"; +import { isAnthropicOAuthToken } from "@oh-my-pi/pi-catalog/utils"; +import { parseGitHubCopilotApiKey } from "@oh-my-pi/pi-catalog/wire/github-copilot"; import { $env, extractHttpStatusFromError, @@ -12,15 +21,7 @@ import { logger, readSseEvents, } from "@oh-my-pi/pi-utils"; -import { - hasOpus47ApiRestrictions, - isAnthropicFableOrMythosModel, - mapEffortToAnthropicAdaptiveEffort, - supportsMidConversationSystemMessages, -} from "../model-thinking"; -import { calculateCost } from "../models"; import { isUsageLimitError } from "../rate-limit-utils"; -import { parseGitHubCopilotApiKey } from "../registry/oauth/github-copilot"; import { getEnvApiKey, OUTPUT_FALLBACK_BUFFER } from "../stream"; import type { Api, @@ -47,13 +48,7 @@ import type { Usage, } from "../types"; import { resolveServiceTier } from "../types"; -import { - isAnthropicOAuthToken, - isRecord, - normalizeSystemPrompts, - normalizeToolCallId, - resolveCacheRetention, -} from "../utils"; +import { isRecord, normalizeSystemPrompts, normalizeToolCallId, resolveCacheRetention } from "../utils"; import { createAbortSourceTracker } from "../utils/abort"; import { AssistantMessageEventStream } from "../utils/event-stream"; import { isFoundryEnabled } from "../utils/foundry"; diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index 532dac027..d60ce6d8e 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -3,35 +3,7 @@ import * as fs from "node:fs/promises"; import http2 from "node:http2"; import { create, fromBinary, fromJson, type JsonValue, toBinary, toJson } from "@bufbuild/protobuf"; import { ValueSchema } from "@bufbuild/protobuf/wkt"; -import { $env, extractHttpStatusFromError, sanitizeText } from "@oh-my-pi/pi-utils"; -import { calculateCost } from "../models"; -import type { - Api, - AssistantMessage, - Context, - CursorExecHandlerResult, - CursorExecHandlers, - CursorMcpCall, - CursorShellStreamCallbacks, - CursorToolResultHandler, - ImageContent, - Message, - Model, - StreamFunction, - StreamOptions, - TextContent, - ThinkingContent, - Tool, - ToolCall, - ToolResultMessage, -} from "../types"; -import { normalizeSystemPrompts } from "../utils"; -import { AssistantMessageEventStream } from "../utils/event-stream"; -import { parseStreamingJson } from "../utils/json-parse"; -import { createRequestDebugSession, isRequestDebugEnabled, type RequestDebugResponseLog } from "../utils/request-debug"; -import { formatErrorMessageWithRetryAfter } from "../utils/retry-after"; -import { toolWireSchema } from "../utils/schema/wire"; -import type { McpToolDefinition } from "./cursor/gen/agent_pb"; +import type { McpToolDefinition } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb"; import { AgentClientMessageSchema, AgentConversationTurnStructureSchema, @@ -128,7 +100,35 @@ import { WriteShellStdinErrorSchema, WriteShellStdinResultSchema, WriteSuccessSchema, -} from "./cursor/gen/agent_pb"; +} from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb"; +import { calculateCost } from "@oh-my-pi/pi-catalog/models"; +import { $env, extractHttpStatusFromError, sanitizeText } from "@oh-my-pi/pi-utils"; +import type { + Api, + AssistantMessage, + Context, + CursorExecHandlerResult, + CursorExecHandlers, + CursorMcpCall, + CursorShellStreamCallbacks, + CursorToolResultHandler, + ImageContent, + Message, + Model, + StreamFunction, + StreamOptions, + TextContent, + ThinkingContent, + Tool, + ToolCall, + ToolResultMessage, +} from "../types"; +import { normalizeSystemPrompts } from "../utils"; +import { AssistantMessageEventStream } from "../utils/event-stream"; +import { parseStreamingJson } from "../utils/json-parse"; +import { createRequestDebugSession, isRequestDebugEnabled, type RequestDebugResponseLog } from "../utils/request-debug"; +import { formatErrorMessageWithRetryAfter } from "../utils/retry-after"; +import { toolWireSchema } from "../utils/schema/wire"; export const CURSOR_API_URL = "https://api2.cursor.sh"; export const CURSOR_CLIENT_VERSION = "cli-2026.01.09-231024f"; diff --git a/packages/ai/src/providers/github-copilot-headers.ts b/packages/ai/src/providers/github-copilot-headers.ts index 39576c369..15b0dfe75 100644 --- a/packages/ai/src/providers/github-copilot-headers.ts +++ b/packages/ai/src/providers/github-copilot-headers.ts @@ -1,4 +1,4 @@ -import { getGitHubCopilotBaseUrl, parseGitHubCopilotApiKey } from "../registry/oauth/github-copilot"; +import { getGitHubCopilotBaseUrl, parseGitHubCopilotApiKey } from "@oh-my-pi/pi-catalog/wire/github-copilot"; import type { Message } from "../types"; /** * Infer whether the current request to Copilot is user-initiated or agent-initiated. diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index c2d32dcfa..313593f3c 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -5,8 +5,13 @@ */ import { createHash, randomBytes, randomUUID } from "node:crypto"; import { scheduler } from "node:timers/promises"; +import { calculateCost } from "@oh-my-pi/pi-catalog/models"; +import { + ANTIGRAVITY_SYSTEM_INSTRUCTION, + getAntigravityUserAgent, + getGeminiCliHeaders, +} from "@oh-my-pi/pi-catalog/wire/gemini-headers"; import { extractHttpStatusFromError, fetchWithRetry, readSseJson } from "@oh-my-pi/pi-utils"; -import { calculateCost } from "../models"; import type { Api, AssistantMessage, @@ -24,7 +29,6 @@ import { appendRawHttpRequestDumpFor400, type RawHttpRequestDump, withHttpStatus // Refresh is the sole responsibility of AuthStorage (broker-aware, single-flighted); // the stream provider trusts the access token threaded through `options.apiKey`. import { normalizeSchemaForCCA } from "../utils/schema"; -import { ANTIGRAVITY_SYSTEM_INSTRUCTION, getAntigravityUserAgent, getGeminiCliHeaders } from "./google-gemini-headers"; import type { Content, FunctionCallingConfigMode, ThinkingConfig } from "./google-shared"; import { convertMessages, @@ -80,7 +84,7 @@ export { getAntigravityUserAgent, getGeminiCliHeaders, getGeminiCliUserAgent, -} from "./google-gemini-headers"; +} from "@oh-my-pi/pi-catalog/wire/gemini-headers"; // Retry configuration const MAX_RETRIES = 3; diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index 3c7d23604..cd5a09e3f 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -2,8 +2,8 @@ * Shared utilities for Google Generative AI and Google Cloud Code Assist providers. */ +import { calculateCost } from "@oh-my-pi/pi-catalog/models"; import { extractHttpStatusFromError, readSseJson } from "@oh-my-pi/pi-utils"; -import { calculateCost } from "../models"; import type { Api, AssistantMessage, diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index f7a7d283a..c27ea61f7 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -1,5 +1,12 @@ import * as os from "node:os"; import { scheduler } from "node:timers/promises"; +import { calculateCost } from "@oh-my-pi/pi-catalog/models"; +import { + CODEX_BASE_URL, + getCodexAccountId, + OPENAI_HEADER_VALUES, + OPENAI_HEADERS, +} from "@oh-my-pi/pi-catalog/wire/codex"; import { $env, $flag, @@ -20,7 +27,6 @@ import type { ResponseReasoningItem, } from "openai/resources/responses/responses"; import packageJson from "../../package.json" with { type: "json" }; -import { calculateCost } from "../models"; import { getEnvApiKey } from "../stream"; import { type Api, @@ -58,7 +64,6 @@ import { createRequestDebugSession, isRequestDebugEnabled, type RequestDebugResp import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema"; import { notifyRawSseEvent } from "../utils/sse-debug"; import { compactGrammarDefinition } from "./grammar"; -import { CODEX_BASE_URL, getCodexAccountId, OPENAI_HEADER_VALUES, OPENAI_HEADERS } from "./openai-codex/constants"; import { type CodexRequestOptions, type InputItem, diff --git a/packages/ai/src/providers/openai-codex/request-transformer.ts b/packages/ai/src/providers/openai-codex/request-transformer.ts index 342708b2e..739e63851 100644 --- a/packages/ai/src/providers/openai-codex/request-transformer.ts +++ b/packages/ai/src/providers/openai-codex/request-transformer.ts @@ -1,5 +1,5 @@ -import type { Effort } from "../../effort"; -import { requireSupportedEffort } from "../../model-thinking"; +import type { Effort } from "@oh-my-pi/pi-catalog/effort"; +import { requireSupportedEffort } from "@oh-my-pi/pi-catalog/model-thinking"; import type { Api, Model } from "../../types"; export interface ReasoningConfig { diff --git a/packages/ai/src/providers/openai-codex/response-handler.ts b/packages/ai/src/providers/openai-codex/response-handler.ts index 3ca952ba0..8fc0c850a 100644 --- a/packages/ai/src/providers/openai-codex/response-handler.ts +++ b/packages/ai/src/providers/openai-codex/response-handler.ts @@ -1,4 +1,4 @@ -import { toNumber } from "../../utils"; +import { toNumber } from "@oh-my-pi/pi-catalog/utils"; export type CodexRateLimit = { used_percent?: number; diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 250f99ff2..bf30aed6a 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -1,3 +1,9 @@ +import { detectOpenAICompat, type ResolvedOpenAICompat, resolveOpenAICompat } from "@oh-my-pi/pi-catalog/compat/openai"; +import type { Effort } from "@oh-my-pi/pi-catalog/effort"; +import { toFirepassWireModelId, toFireworksWireModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id"; +import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking"; +import { calculateCost } from "@oh-my-pi/pi-catalog/models"; +import { parseGitHubCopilotApiKey } from "@oh-my-pi/pi-catalog/wire/github-copilot"; import { $env, extractHttpStatusFromError } from "@oh-my-pi/pi-utils"; import OpenAI, { APIConnectionTimeoutError as OpenAIConnectionTimeoutError } from "openai"; import type { @@ -10,10 +16,6 @@ import type { ChatCompletionToolMessageParam, } from "openai/resources/chat/completions"; import packageJson from "../../package.json" with { type: "json" }; -import type { Effort } from "../effort"; -import { getSupportedEfforts } from "../model-thinking"; -import { calculateCost } from "../models"; -import { parseGitHubCopilotApiKey } from "../registry/oauth/github-copilot"; import { getKimiCommonHeaders } from "../registry/oauth/kimi"; import { getEnvApiKey } from "../stream"; import { @@ -43,7 +45,6 @@ import { import { normalizeSystemPrompts } from "../utils"; import { createAbortSourceTracker } from "../utils/abort"; import { AssistantMessageEventStream } from "../utils/event-stream"; -import { toFirepassWireModelId, toFireworksWireModelId } from "../utils/fireworks-model-id"; import { type CapturedHttpErrorResponse, finalizeErrorMessage, @@ -73,7 +74,6 @@ import { hasCopilotVisionInput, resolveGitHubCopilotBaseUrl, } from "./github-copilot-headers"; -import { detectOpenAICompat, type ResolvedOpenAICompat, resolveOpenAICompat } from "./openai-completions-compat"; import { createInitialResponsesAssistantMessage } from "./openai-responses-shared"; import { transformMessages } from "./transform-messages"; import { diff --git a/packages/ai/src/providers/openai-responses-shared.ts b/packages/ai/src/providers/openai-responses-shared.ts index 6c399b9e5..db589ceae 100644 --- a/packages/ai/src/providers/openai-responses-shared.ts +++ b/packages/ai/src/providers/openai-responses-shared.ts @@ -1,3 +1,4 @@ +import { calculateCost } from "@oh-my-pi/pi-catalog/models"; import { logger, structuredCloneJSON } from "@oh-my-pi/pi-utils"; import type OpenAI from "openai"; import type { @@ -11,7 +12,6 @@ import type { ResponseOutputMessage, ResponseReasoningItem, } from "openai/resources/responses/responses"; -import { calculateCost } from "../models"; import { type Api, type AssistantMessage, diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 05591b3a1..45ac36a0b 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -1,3 +1,4 @@ +import { parseGitHubCopilotApiKey } from "@oh-my-pi/pi-catalog/wire/github-copilot"; import { $env, extractHttpStatusFromError } from "@oh-my-pi/pi-utils"; import OpenAI, { APIConnectionTimeoutError as OpenAIConnectionTimeoutError } from "openai"; import type { @@ -6,7 +7,6 @@ import type { ResponseInput, ResponseStreamEvent, } from "openai/resources/responses/responses"; -import { parseGitHubCopilotApiKey } from "../registry/oauth/github-copilot"; import { getEnvApiKey } from "../stream"; import type { AssistantMessage, diff --git a/packages/ai/src/registry/aimlapi.ts b/packages/ai/src/registry/aimlapi.ts index 6d3067f33..d5c3d3929 100644 --- a/packages/ai/src/registry/aimlapi.ts +++ b/packages/ai/src/registry/aimlapi.ts @@ -1,12 +1,6 @@ -import { aimlApiModelManagerOptions } from "../provider-models/openai-compat"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; export const aimlApiProvider = { id: "aimlapi", name: "AIML API", - defaultModel: "gpt-4o", - createModelManagerOptions: (config: ModelManagerConfig) => aimlApiModelManagerOptions(config), - dynamicModelsAuthoritative: true, - catalogDiscovery: { label: "AIML API", envVars: ["AIMLAPI_API_KEY"] }, - envKeys: "AIMLAPI_API_KEY", } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/alibaba-coding-plan.ts b/packages/ai/src/registry/alibaba-coding-plan.ts index c9dc04878..f23f5b54e 100644 --- a/packages/ai/src/registry/alibaba-coding-plan.ts +++ b/packages/ai/src/registry/alibaba-coding-plan.ts @@ -1,7 +1,6 @@ -import { alibabaCodingPlanModelManagerOptions } from "../provider-models/openai-compat"; import { validateOpenAICompatibleApiKey } from "./api-key-validation"; import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; const AUTH_URL = "https://modelstudio.console.alibabacloud.com/"; const API_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1"; @@ -46,9 +45,5 @@ export async function loginAlibabaCodingPlan(options: OAuthController): Promise< export const alibabaCodingPlanProvider = { id: "alibaba-coding-plan", name: "Alibaba Coding Plan", - defaultModel: "qwen3.5-plus", - createModelManagerOptions: (config: ModelManagerConfig) => alibabaCodingPlanModelManagerOptions(config), - catalogDiscovery: { label: "Alibaba Coding Plan", envVars: ["ALIBABA_CODING_PLAN_API_KEY"] }, - envKeys: "ALIBABA_CODING_PLAN_API_KEY", login: (cb: OAuthLoginCallbacks) => loginAlibabaCodingPlan(cb), } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/amazon-bedrock.ts b/packages/ai/src/registry/amazon-bedrock.ts index 82fd01cdb..222530724 100644 --- a/packages/ai/src/registry/amazon-bedrock.ts +++ b/packages/ai/src/registry/amazon-bedrock.ts @@ -4,7 +4,6 @@ import type { ProviderDefinition } from "./types"; export const amazonBedrockProvider = { id: "amazon-bedrock", name: "Amazon Bedrock", - defaultModel: "us.anthropic.claude-opus-4-6-v1", // Amazon Bedrock accepts bearer tokens, IAM keys, profiles, ECS/IRSA credential chains. envKeys: () => { const hasEcsCredentials = diff --git a/packages/ai/src/registry/anthropic.ts b/packages/ai/src/registry/anthropic.ts index f53b6d7c0..0b831854a 100644 --- a/packages/ai/src/registry/anthropic.ts +++ b/packages/ai/src/registry/anthropic.ts @@ -1,14 +1,11 @@ import { $pickenv } from "@oh-my-pi/pi-utils"; -import { anthropicModelManagerOptions } from "../provider-models/openai-compat"; import { isFoundryEnabled } from "../utils/foundry"; import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; export const anthropicProvider = { id: "anthropic", name: "Anthropic (Claude Pro/Max)", - defaultModel: "claude-opus-4-6", - createModelManagerOptions: (config: ModelManagerConfig) => anthropicModelManagerOptions(config), // Foundry mode optionally switches Anthropic auth to enterprise gateway credentials. envKeys: () => isFoundryEnabled() diff --git a/packages/ai/src/registry/cerebras.ts b/packages/ai/src/registry/cerebras.ts index 98016c366..39f767ff0 100644 --- a/packages/ai/src/registry/cerebras.ts +++ b/packages/ai/src/registry/cerebras.ts @@ -1,7 +1,6 @@ -import { cerebrasModelManagerOptions } from "../provider-models/openai-compat"; import { createApiKeyLogin } from "./api-key-login"; import type { OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; export const loginCerebras = createApiKeyLogin({ providerLabel: "Cerebras", @@ -20,9 +19,5 @@ export const loginCerebras = createApiKeyLogin({ export const cerebrasProvider = { id: "cerebras", name: "Cerebras", - defaultModel: "zai-glm-4.6", - createModelManagerOptions: (config: ModelManagerConfig) => cerebrasModelManagerOptions(config), - catalogDiscovery: { label: "Cerebras", envVars: ["CEREBRAS_API_KEY"] }, - envKeys: "CEREBRAS_API_KEY", login: (cb: OAuthLoginCallbacks) => loginCerebras(cb), } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/cloudflare-ai-gateway.ts b/packages/ai/src/registry/cloudflare-ai-gateway.ts index c0b64ab89..bbc424bd9 100644 --- a/packages/ai/src/registry/cloudflare-ai-gateway.ts +++ b/packages/ai/src/registry/cloudflare-ai-gateway.ts @@ -1,6 +1,5 @@ -import { cloudflareAiGatewayModelManagerOptions } from "../provider-models/openai-compat"; import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; const AUTH_URL = "https://developers.cloudflare.com/ai-gateway/configuration/authentication/"; @@ -41,9 +40,5 @@ export async function loginCloudflareAiGateway(options: OAuthController): Promis export const cloudflareAiGatewayProvider = { id: "cloudflare-ai-gateway", name: "Cloudflare AI Gateway", - defaultModel: "claude-sonnet-4-5", - createModelManagerOptions: (config: ModelManagerConfig) => cloudflareAiGatewayModelManagerOptions(config), - catalogDiscovery: { label: "Cloudflare AI Gateway", envVars: ["CLOUDFLARE_AI_GATEWAY_API_KEY"] }, - envKeys: "CLOUDFLARE_AI_GATEWAY_API_KEY", login: (cb: OAuthLoginCallbacks) => loginCloudflareAiGateway(cb), } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/cursor.ts b/packages/ai/src/registry/cursor.ts index 9d143e768..c7c9a2a88 100644 --- a/packages/ai/src/registry/cursor.ts +++ b/packages/ai/src/registry/cursor.ts @@ -1,14 +1,9 @@ -import { cursorModelManagerOptions } from "../provider-models/special"; import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; export const cursorProvider = { id: "cursor", name: "Cursor (Claude, GPT, etc.)", - defaultModel: "claude-sonnet-4-6", - createModelManagerOptions: (config: ModelManagerConfig) => cursorModelManagerOptions(config), - catalogDiscovery: { label: "Cursor", envVars: ["CURSOR_API_KEY"], oauthProvider: "cursor" }, - envKeys: "CURSOR_ACCESS_TOKEN", login: async (cb: OAuthLoginCallbacks) => { // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. const { loginCursor } = await import("./oauth/cursor"); diff --git a/packages/ai/src/registry/deepseek.ts b/packages/ai/src/registry/deepseek.ts index 669f4288b..d37ad6e8b 100644 --- a/packages/ai/src/registry/deepseek.ts +++ b/packages/ai/src/registry/deepseek.ts @@ -1,7 +1,6 @@ -import { deepseekModelManagerOptions } from "../provider-models/openai-compat"; import { createApiKeyLogin } from "./api-key-login"; import type { OAuthController, OAuthLoginCallbacks, OAuthPrompt } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; const innerLogin = createApiKeyLogin({ providerLabel: "DeepSeek", @@ -42,9 +41,5 @@ export const loginDeepSeek = async (options: OAuthController): Promise = export const deepseekProvider = { id: "deepseek", name: "DeepSeek", - defaultModel: "deepseek-v4-pro", - createModelManagerOptions: (config: ModelManagerConfig) => deepseekModelManagerOptions(config), - catalogDiscovery: { label: "DeepSeek", envVars: ["DEEPSEEK_API_KEY"] }, - envKeys: "DEEPSEEK_API_KEY", login: (cb: OAuthLoginCallbacks) => loginDeepSeek(cb), } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/firepass.ts b/packages/ai/src/registry/firepass.ts index 3599c2a38..2129064e4 100644 --- a/packages/ai/src/registry/firepass.ts +++ b/packages/ai/src/registry/firepass.ts @@ -1,7 +1,6 @@ -import { firepassModelManagerOptions } from "../provider-models/openai-compat"; import { createApiKeyLogin } from "./api-key-login"; import type { OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; /** * Fire Pass login flow. @@ -29,8 +28,5 @@ export const loginFirepass = createApiKeyLogin({ export const firepassProvider = { id: "firepass", name: "Fire Pass (Fireworks Kimi K2.6 Turbo subscription)", - defaultModel: "kimi-k2.6-turbo", - createModelManagerOptions: (config: ModelManagerConfig) => firepassModelManagerOptions(config), - envKeys: "FIREPASS_API_KEY", login: (cb: OAuthLoginCallbacks) => loginFirepass(cb), } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/fireworks.ts b/packages/ai/src/registry/fireworks.ts index 20d17d91d..e2e73e443 100644 --- a/packages/ai/src/registry/fireworks.ts +++ b/packages/ai/src/registry/fireworks.ts @@ -1,7 +1,6 @@ -import { fireworksModelManagerOptions } from "../provider-models/openai-compat"; import { createApiKeyLogin } from "./api-key-login"; import type { OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; export const loginFireworks = createApiKeyLogin({ providerLabel: "Fireworks", @@ -19,9 +18,5 @@ export const loginFireworks = createApiKeyLogin({ export const fireworksProvider = { id: "fireworks", name: "Fireworks", - defaultModel: "kimi-k2.6", - createModelManagerOptions: (config: ModelManagerConfig) => fireworksModelManagerOptions(config), - catalogDiscovery: { label: "Fireworks", envVars: ["FIREWORKS_API_KEY"] }, - envKeys: "FIREWORKS_API_KEY", login: (cb: OAuthLoginCallbacks) => loginFireworks(cb), } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/github-copilot.ts b/packages/ai/src/registry/github-copilot.ts index b8e757ac6..4d3f240ea 100644 --- a/packages/ai/src/registry/github-copilot.ts +++ b/packages/ai/src/registry/github-copilot.ts @@ -1,13 +1,9 @@ -import { githubCopilotModelManagerOptions } from "../provider-models/openai-compat"; import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; export const githubCopilotProvider = { id: "github-copilot", name: "GitHub Copilot", - defaultModel: "gpt-4o", - createModelManagerOptions: (config: ModelManagerConfig) => githubCopilotModelManagerOptions(config), - envKeys: "COPILOT_GITHUB_TOKEN", login: async (cb: OAuthLoginCallbacks) => { // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. const { loginGitHubCopilot } = await import("./oauth/github-copilot"); diff --git a/packages/ai/src/registry/gitlab-duo.ts b/packages/ai/src/registry/gitlab-duo.ts index aa6bf3775..11b7ed13c 100644 --- a/packages/ai/src/registry/gitlab-duo.ts +++ b/packages/ai/src/registry/gitlab-duo.ts @@ -4,8 +4,6 @@ import type { ProviderDefinition } from "./types"; export const gitlabDuoProvider = { id: "gitlab-duo", name: "GitLab Duo", - defaultModel: "duo-chat-sonnet-4-5", - envKeys: "GITLAB_TOKEN", login: async (cb: OAuthLoginCallbacks) => { // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. const { loginGitLabDuo } = await import("./oauth/gitlab-duo"); diff --git a/packages/ai/src/registry/google-antigravity.ts b/packages/ai/src/registry/google-antigravity.ts index beeda90cc..78d87323a 100644 --- a/packages/ai/src/registry/google-antigravity.ts +++ b/packages/ai/src/registry/google-antigravity.ts @@ -4,8 +4,6 @@ import type { ProviderDefinition } from "./types"; export const googleAntigravityProvider = { id: "google-antigravity", name: "Antigravity (Gemini 3, Claude, GPT-OSS)", - defaultModel: "gemini-3-pro-high", - specialModelManager: true, login: async (cb: OAuthLoginCallbacks) => { // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. const { loginAntigravity } = await import("./oauth/google-antigravity"); diff --git a/packages/ai/src/registry/google-gemini-cli.ts b/packages/ai/src/registry/google-gemini-cli.ts index e0552340f..22b537c33 100644 --- a/packages/ai/src/registry/google-gemini-cli.ts +++ b/packages/ai/src/registry/google-gemini-cli.ts @@ -4,8 +4,6 @@ import type { ProviderDefinition } from "./types"; export const googleGeminiCliProvider = { id: "google-gemini-cli", name: "Google Cloud Code Assist (Gemini CLI)", - defaultModel: "gemini-2.5-pro", - specialModelManager: true, login: async (cb: OAuthLoginCallbacks) => { // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. const { loginGeminiCli } = await import("./oauth/google-gemini-cli"); diff --git a/packages/ai/src/registry/google-vertex.ts b/packages/ai/src/registry/google-vertex.ts index 9dc6ca9b3..c58b20fb2 100644 --- a/packages/ai/src/registry/google-vertex.ts +++ b/packages/ai/src/registry/google-vertex.ts @@ -2,8 +2,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { $env } from "@oh-my-pi/pi-utils"; -import { googleVertexModelManagerOptions } from "../provider-models/google"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; let cachedVertexAdcCredentialsExists: boolean | null = null; @@ -24,9 +23,6 @@ function hasVertexAdcCredentials(): boolean { export const googleVertexProvider = { id: "google-vertex", name: "Google Vertex AI", - defaultModel: "gemini-3-pro-preview", - createModelManagerOptions: (config: ModelManagerConfig) => googleVertexModelManagerOptions(config), - allowUnauthenticated: true, // Vertex AI supports either GOOGLE_CLOUD_API_KEY or Application Default Credentials. envKeys: () => { if ($env.GOOGLE_CLOUD_API_KEY) { diff --git a/packages/ai/src/registry/google.ts b/packages/ai/src/registry/google.ts index 2f4bfca49..c50bf422d 100644 --- a/packages/ai/src/registry/google.ts +++ b/packages/ai/src/registry/google.ts @@ -1,10 +1,6 @@ -import { googleModelManagerOptions } from "../provider-models/google"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; export const googleProvider = { id: "google", name: "Google Gemini", - defaultModel: "gemini-2.5-pro", - createModelManagerOptions: (config: ModelManagerConfig) => googleModelManagerOptions(config), - envKeys: "GEMINI_API_KEY", } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/groq.ts b/packages/ai/src/registry/groq.ts index 7c636d6f7..898ab4596 100644 --- a/packages/ai/src/registry/groq.ts +++ b/packages/ai/src/registry/groq.ts @@ -1,10 +1,6 @@ -import { groqModelManagerOptions } from "../provider-models/openai-compat"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; export const groqProvider = { id: "groq", name: "Groq", - defaultModel: "openai/gpt-oss-120b", - createModelManagerOptions: (config: ModelManagerConfig) => groqModelManagerOptions(config), - envKeys: "GROQ_API_KEY", } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/huggingface.ts b/packages/ai/src/registry/huggingface.ts index d5d0f4115..d66c169e2 100644 --- a/packages/ai/src/registry/huggingface.ts +++ b/packages/ai/src/registry/huggingface.ts @@ -1,8 +1,6 @@ -import { $pickenv } from "@oh-my-pi/pi-utils"; -import { huggingfaceModelManagerOptions } from "../provider-models/openai-compat"; import { validateOpenAICompatibleApiKey } from "./api-key-validation"; import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; const AUTH_URL = "https://huggingface.co/settings/tokens/new?ownUserPermissions=inference.serverless.write&tokenType=fineGrained"; @@ -49,9 +47,5 @@ export async function loginHuggingface(options: OAuthController): Promise huggingfaceModelManagerOptions(config), - catalogDiscovery: { label: "Hugging Face", envVars: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"] }, - envKeys: () => $pickenv("HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"), login: (cb: OAuthLoginCallbacks) => loginHuggingface(cb), } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/kilo.ts b/packages/ai/src/registry/kilo.ts index 5ba36224b..450c56cdf 100644 --- a/packages/ai/src/registry/kilo.ts +++ b/packages/ai/src/registry/kilo.ts @@ -1,6 +1,5 @@ -import { kiloModelManagerOptions } from "../provider-models/openai-compat"; import type { OAuthController, OAuthCredentials } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; const KILO_DEVICE_AUTH_BASE_URL = "https://api.kilo.ai/api/device-auth"; const POLL_INTERVAL_MS = 5000; @@ -89,9 +88,5 @@ export async function loginKilo(callbacks: OAuthController): Promise kiloModelManagerOptions(config), - catalogDiscovery: { label: "Kilo Gateway", envVars: ["KILO_API_KEY"], allowUnauthenticated: true }, - envKeys: "KILO_API_KEY", login: loginKilo, } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/kimi-code.ts b/packages/ai/src/registry/kimi-code.ts index aedb23beb..f9b162b0d 100644 --- a/packages/ai/src/registry/kimi-code.ts +++ b/packages/ai/src/registry/kimi-code.ts @@ -1,13 +1,9 @@ -import { kimiCodeModelManagerOptions } from "../provider-models/openai-compat"; import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; export const kimiCodeProvider = { id: "kimi-code", name: "Kimi Code", - defaultModel: "kimi-k2.5", - createModelManagerOptions: (config: ModelManagerConfig) => kimiCodeModelManagerOptions(config), - catalogDiscovery: { label: "Kimi Code", envVars: ["KIMI_API_KEY"] }, login: async (cb: OAuthLoginCallbacks) => { // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. const { loginKimi } = await import("./oauth/kimi"); diff --git a/packages/ai/src/registry/litellm.ts b/packages/ai/src/registry/litellm.ts index a84072092..d32babfc6 100644 --- a/packages/ai/src/registry/litellm.ts +++ b/packages/ai/src/registry/litellm.ts @@ -1,6 +1,5 @@ -import { litellmModelManagerOptions } from "../provider-models/openai-compat"; import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; const AUTH_URL = "https://docs.litellm.ai/docs/proxy/deploy"; @@ -40,9 +39,5 @@ export async function loginLiteLLM(options: OAuthController): Promise { export const litellmProvider = { id: "litellm", name: "LiteLLM", - defaultModel: "claude-opus-4-6", - createModelManagerOptions: (config: ModelManagerConfig) => litellmModelManagerOptions(config), - catalogDiscovery: { label: "LiteLLM", envVars: ["LITELLM_API_KEY"], allowUnauthenticated: true }, - envKeys: "LITELLM_API_KEY", login: (cb: OAuthLoginCallbacks) => loginLiteLLM(cb), } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/lm-studio.ts b/packages/ai/src/registry/lm-studio.ts index 869560b59..6f8741b0c 100644 --- a/packages/ai/src/registry/lm-studio.ts +++ b/packages/ai/src/registry/lm-studio.ts @@ -1,6 +1,5 @@ -import { lmStudioModelManagerOptions } from "../provider-models/openai-compat"; import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; const PROVIDER_ID = "lm-studio"; export const DEFAULT_LOCAL_TOKEN = "lm-studio-local"; @@ -27,9 +26,5 @@ export async function loginLmStudio(options: OAuthController): Promise { export const lmStudioProvider = { id: "lm-studio", name: "LM Studio (Local OpenAI-compatible)", - defaultModel: "llama-3-8b", - createModelManagerOptions: (config: ModelManagerConfig) => lmStudioModelManagerOptions(config), - allowUnauthenticated: true, - envKeys: "LM_STUDIO_API_KEY", login: (cb: OAuthLoginCallbacks) => loginLmStudio(cb), } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/minimax-code-cn.ts b/packages/ai/src/registry/minimax-code-cn.ts index c6b1ccd85..05c0c786d 100644 --- a/packages/ai/src/registry/minimax-code-cn.ts +++ b/packages/ai/src/registry/minimax-code-cn.ts @@ -4,8 +4,6 @@ import type { ProviderDefinition } from "./types"; export const minimaxCodeCnProvider = { id: "minimax-code-cn", name: "MiniMax Coding Plan (China)", - defaultModel: "MiniMax-M2.5", - envKeys: "MINIMAX_CODE_CN_API_KEY", login: async (cb: OAuthLoginCallbacks) => { // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. const { loginMiniMaxCodeCn } = await import("./oauth/minimax-code"); diff --git a/packages/ai/src/registry/minimax-code.ts b/packages/ai/src/registry/minimax-code.ts index a9733d32f..ead92a77d 100644 --- a/packages/ai/src/registry/minimax-code.ts +++ b/packages/ai/src/registry/minimax-code.ts @@ -4,8 +4,6 @@ import type { ProviderDefinition } from "./types"; export const minimaxCodeProvider = { id: "minimax-code", name: "MiniMax Coding Plan (International)", - defaultModel: "MiniMax-M2.5", - envKeys: "MINIMAX_CODE_API_KEY", login: async (cb: OAuthLoginCallbacks) => { // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. const { loginMiniMaxCode } = await import("./oauth/minimax-code"); diff --git a/packages/ai/src/registry/minimax.ts b/packages/ai/src/registry/minimax.ts index c6ff9ae1f..0215a6b8e 100644 --- a/packages/ai/src/registry/minimax.ts +++ b/packages/ai/src/registry/minimax.ts @@ -3,6 +3,4 @@ import type { ProviderDefinition } from "./types"; export const minimaxProvider = { id: "minimax", name: "MiniMax", - defaultModel: "MiniMax-M2.5", - envKeys: "MINIMAX_API_KEY", } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/mistral.ts b/packages/ai/src/registry/mistral.ts index b9bfc634d..758fac5e0 100644 --- a/packages/ai/src/registry/mistral.ts +++ b/packages/ai/src/registry/mistral.ts @@ -1,10 +1,6 @@ -import { mistralModelManagerOptions } from "../provider-models/openai-compat"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; export const mistralProvider = { id: "mistral", name: "Mistral", - defaultModel: "devstral-medium-latest", - createModelManagerOptions: (config: ModelManagerConfig) => mistralModelManagerOptions(config), - envKeys: "MISTRAL_API_KEY", } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/moonshot.ts b/packages/ai/src/registry/moonshot.ts index 52fa5dfea..7b38a541c 100644 --- a/packages/ai/src/registry/moonshot.ts +++ b/packages/ai/src/registry/moonshot.ts @@ -1,7 +1,6 @@ -import { moonshotModelManagerOptions } from "../provider-models/openai-compat"; import { createApiKeyLogin } from "./api-key-login"; import type { OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; export const loginMoonshot = createApiKeyLogin({ providerLabel: "Moonshot", @@ -19,9 +18,5 @@ export const loginMoonshot = createApiKeyLogin({ export const moonshotProvider = { id: "moonshot", name: "Moonshot (Kimi API)", - defaultModel: "kimi-k2.5", - createModelManagerOptions: (config: ModelManagerConfig) => moonshotModelManagerOptions(config), - catalogDiscovery: { label: "Moonshot", envVars: ["MOONSHOT_API_KEY"] }, - envKeys: "MOONSHOT_API_KEY", login: (cb: OAuthLoginCallbacks) => loginMoonshot(cb), } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/nanogpt.ts b/packages/ai/src/registry/nanogpt.ts index a04b8da99..c215cafa7 100644 --- a/packages/ai/src/registry/nanogpt.ts +++ b/packages/ai/src/registry/nanogpt.ts @@ -1,7 +1,6 @@ -import { nanoGptModelManagerOptions } from "../provider-models/openai-compat"; import { createApiKeyLogin } from "./api-key-login"; import type { OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; export const loginNanoGPT = createApiKeyLogin({ providerLabel: "NanoGPT", @@ -19,9 +18,5 @@ export const loginNanoGPT = createApiKeyLogin({ export const nanogptProvider = { id: "nanogpt", name: "NanoGPT", - defaultModel: "openai/gpt-5.4", - createModelManagerOptions: (config: ModelManagerConfig) => nanoGptModelManagerOptions(config), - catalogDiscovery: { label: "NanoGPT", envVars: ["NANO_GPT_API_KEY"] }, - envKeys: "NANO_GPT_API_KEY", login: (cb: OAuthLoginCallbacks) => loginNanoGPT(cb), } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/nvidia.ts b/packages/ai/src/registry/nvidia.ts index 35a90af44..9425cb821 100644 --- a/packages/ai/src/registry/nvidia.ts +++ b/packages/ai/src/registry/nvidia.ts @@ -1,7 +1,6 @@ -import { nvidiaModelManagerOptions } from "../provider-models/openai-compat"; import { validateOpenAICompatibleApiKey } from "./api-key-validation"; import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; const AUTH_URL = "https://org.ngc.nvidia.com/setup/personal-keys"; const API_BASE_URL = "https://integrate.api.nvidia.com/v1"; @@ -57,9 +56,5 @@ export async function loginNvidia(options: OAuthController): Promise { export const nvidiaProvider = { id: "nvidia", name: "NVIDIA", - defaultModel: "nvidia/llama-3.1-nemotron-70b-instruct", - createModelManagerOptions: (config: ModelManagerConfig) => nvidiaModelManagerOptions(config), - catalogDiscovery: { label: "NVIDIA", envVars: ["NVIDIA_API_KEY"] }, - envKeys: "NVIDIA_API_KEY", login: (cb: OAuthLoginCallbacks) => loginNvidia(cb), } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/oauth/github-copilot.ts b/packages/ai/src/registry/oauth/github-copilot.ts index f42677104..21a7ae5f7 100644 --- a/packages/ai/src/registry/oauth/github-copilot.ts +++ b/packages/ai/src/registry/oauth/github-copilot.ts @@ -2,18 +2,19 @@ * GitHub Copilot OAuth flow (opencode OAuth app) */ import { scheduler } from "node:timers/promises"; -import { getBundledModels } from "../../models"; +import { getBundledModels } from "@oh-my-pi/pi-catalog/models"; +import { + getGitHubCopilotBaseUrl, + isPublicGitHubHost, + normalizeDomain, + normalizeGitHubCopilotEnterpriseDomain, + OPENCODE_HEADERS, +} from "@oh-my-pi/pi-catalog/wire/github-copilot"; import type { FetchImpl } from "../../types"; import type { OAuthCredentials } from "./types"; const CLIENT_ID = "Ov23li8tweQw6odWQebz"; -export const COPILOT_USER_AGENT = "opencode/1.3.15" as const; - -export const OPENCODE_HEADERS = { - "User-Agent": COPILOT_USER_AGENT, -} as const; - const INITIAL_POLL_INTERVAL_MULTIPLIER = 1.2; const SLOW_DOWN_POLL_INTERVAL_MULTIPLIER = 1.4; @@ -46,58 +47,6 @@ type DeviceTokenErrorResponse = { interval?: number; }; -type GitHubCopilotApiKeyPayload = { - token?: unknown; - enterpriseUrl?: unknown; -}; - -export type ParsedGitHubCopilotApiKey = { - accessToken: string; - enterpriseUrl?: string; -}; - -const PUBLIC_GITHUB_HOSTS = new Set(["api.github.com", "github.com", "www.github.com"]); - -function isPublicGitHubHost(host: string): boolean { - return PUBLIC_GITHUB_HOSTS.has(host.trim().toLowerCase()); -} - -export function normalizeGitHubCopilotEnterpriseDomain(input: string | undefined): string | undefined { - const trimmed = input?.trim(); - if (!trimmed) return undefined; - const normalized = normalizeDomain(trimmed) ?? trimmed.toLowerCase(); - if (!normalized || isPublicGitHubHost(normalized)) return undefined; - return normalized; -} - -export function parseGitHubCopilotApiKey(apiKeyRaw: string): ParsedGitHubCopilotApiKey { - try { - const parsed = JSON.parse(apiKeyRaw) as GitHubCopilotApiKeyPayload; - if (typeof parsed.token === "string") { - return { - accessToken: parsed.token, - enterpriseUrl: - typeof parsed.enterpriseUrl === "string" - ? normalizeGitHubCopilotEnterpriseDomain(parsed.enterpriseUrl) - : undefined, - }; - } - } catch {} - - return { accessToken: apiKeyRaw }; -} - -export function normalizeDomain(input: string): string | null { - const trimmed = input.trim(); - if (!trimmed) return null; - try { - const url = trimmed.includes("://") ? new URL(trimmed) : new URL(`https://${trimmed}`); - return url.hostname; - } catch { - return null; - } -} - function getUrls(domain: string): { deviceCodeUrl: string; accessTokenUrl: string; @@ -108,15 +57,6 @@ function getUrls(domain: string): { }; } -export function getGitHubCopilotBaseUrl(enterpriseDomain?: string): string { - const normalizedEnterpriseDomain = normalizeGitHubCopilotEnterpriseDomain(enterpriseDomain); - if (!normalizedEnterpriseDomain) return "https://api.githubcopilot.com"; - const host = normalizedEnterpriseDomain.startsWith("copilot-api.") - ? normalizedEnterpriseDomain - : `copilot-api.${normalizedEnterpriseDomain}`; - return `https://${host}`; -} - async function fetchJson(url: string, init: RequestInit, fetchImpl: FetchImpl): Promise { const response = await fetchImpl(url, init); if (!response.ok) { diff --git a/packages/ai/src/registry/oauth/google-antigravity.ts b/packages/ai/src/registry/oauth/google-antigravity.ts index f63ce051b..123e19e89 100644 --- a/packages/ai/src/registry/oauth/google-antigravity.ts +++ b/packages/ai/src/registry/oauth/google-antigravity.ts @@ -2,7 +2,7 @@ * Antigravity OAuth flow (Gemini 3, Claude, GPT-OSS via Google Cloud) * Uses different OAuth credentials than google-gemini-cli for access to additional models. */ -import { getAntigravityUserAgent } from "../../providers/google-gemini-headers"; +import { getAntigravityUserAgent } from "@oh-my-pi/pi-catalog/wire/gemini-headers"; import { runGoogleOAuthLogin } from "./google-oauth-shared"; import type { OAuthController, OAuthCredentials } from "./types"; diff --git a/packages/ai/src/registry/oauth/google-gemini-cli.ts b/packages/ai/src/registry/oauth/google-gemini-cli.ts index e3ea1e7c3..d43f1669a 100644 --- a/packages/ai/src/registry/oauth/google-gemini-cli.ts +++ b/packages/ai/src/registry/oauth/google-gemini-cli.ts @@ -3,8 +3,8 @@ * Standard Gemini models only (gemini-2.0-flash, gemini-2.5-*) */ +import { getGeminiCliHeaders } from "@oh-my-pi/pi-catalog/wire/gemini-headers"; import { $env } from "@oh-my-pi/pi-utils"; -import { getGeminiCliHeaders } from "../../providers/google-gemini-headers"; import { runGoogleOAuthLogin } from "./google-oauth-shared"; import type { OAuthController, OAuthCredentials } from "./types"; diff --git a/packages/ai/src/registry/ollama-cloud.ts b/packages/ai/src/registry/ollama-cloud.ts index 322c042b1..4dd6d74c6 100644 --- a/packages/ai/src/registry/ollama-cloud.ts +++ b/packages/ai/src/registry/ollama-cloud.ts @@ -1,6 +1,5 @@ -import { ollamaCloudModelManagerOptions } from "../provider-models/ollama"; import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; const OLLAMA_CLOUD_KEYS_URL = "https://ollama.com/settings/keys"; @@ -32,9 +31,5 @@ export async function loginOllamaCloud(options: OAuthController): Promise ollamaCloudModelManagerOptions(config), - catalogDiscovery: { label: "Ollama Cloud", envVars: ["OLLAMA_CLOUD_API_KEY"], oauthProvider: "ollama-cloud" }, - envKeys: "OLLAMA_CLOUD_API_KEY", login: (cb: OAuthLoginCallbacks) => loginOllamaCloud(cb), } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/ollama.ts b/packages/ai/src/registry/ollama.ts index 375452e51..1566dea5d 100644 --- a/packages/ai/src/registry/ollama.ts +++ b/packages/ai/src/registry/ollama.ts @@ -1,6 +1,5 @@ -import { ollamaModelManagerOptions } from "../provider-models/openai-compat"; import type { OAuthController } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; const OLLAMA_DOCS_URL = "https://github.com/ollama/ollama/blob/main/docs/api.md"; @@ -39,9 +38,5 @@ export async function loginOllama(options: OAuthController): Promise { export const ollamaProvider = { id: "ollama", name: "Ollama (Local OpenAI-compatible)", - defaultModel: "gpt-oss:20b", - createModelManagerOptions: (config: ModelManagerConfig) => ollamaModelManagerOptions(config), - allowUnauthenticated: true, login: loginOllama, - envKeys: "OLLAMA_API_KEY", } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/openai-codex.ts b/packages/ai/src/registry/openai-codex.ts index 3957bf788..60df0e70e 100644 --- a/packages/ai/src/registry/openai-codex.ts +++ b/packages/ai/src/registry/openai-codex.ts @@ -4,9 +4,6 @@ import type { ProviderDefinition } from "./types"; export const openaiCodexProvider = { id: "openai-codex", name: "ChatGPT Plus/Pro (Codex Subscription)", - defaultModel: "gpt-5.4", - specialModelManager: true, - envKeys: "OPENAI_CODEX_OAUTH_TOKEN", login: async (cb: OAuthLoginCallbacks) => { // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. const { loginOpenAICodex } = await import("./oauth/openai-codex"); diff --git a/packages/ai/src/registry/openai.ts b/packages/ai/src/registry/openai.ts index 1fe6b3c33..f42aebc3e 100644 --- a/packages/ai/src/registry/openai.ts +++ b/packages/ai/src/registry/openai.ts @@ -1,10 +1,6 @@ -import { openaiModelManagerOptions } from "../provider-models/openai-compat"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; export const openaiProvider = { id: "openai", name: "OpenAI", - defaultModel: "gpt-5.4", - createModelManagerOptions: (config: ModelManagerConfig) => openaiModelManagerOptions(config), - envKeys: "OPENAI_API_KEY", } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/opencode-go.ts b/packages/ai/src/registry/opencode-go.ts index b9dfdaf92..0dbdd4310 100644 --- a/packages/ai/src/registry/opencode-go.ts +++ b/packages/ai/src/registry/opencode-go.ts @@ -1,13 +1,9 @@ -import { opencodeGoModelManagerOptions } from "../provider-models/openai-compat"; import type { OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; export const opencodeGoProvider = { id: "opencode-go", name: "OpenCode Go", - defaultModel: "kimi-k2.5", - createModelManagerOptions: (config: ModelManagerConfig) => opencodeGoModelManagerOptions(config), - envKeys: "OPENCODE_API_KEY", login: async (cb: OAuthLoginCallbacks) => { // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. const { loginOpenCode } = await import("./oauth/opencode"); diff --git a/packages/ai/src/registry/opencode-zen.ts b/packages/ai/src/registry/opencode-zen.ts index 87e652e13..cd1f1421d 100644 --- a/packages/ai/src/registry/opencode-zen.ts +++ b/packages/ai/src/registry/opencode-zen.ts @@ -1,13 +1,9 @@ -import { opencodeZenModelManagerOptions } from "../provider-models/openai-compat"; import type { OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; export const opencodeZenProvider = { id: "opencode-zen", name: "OpenCode Zen", - defaultModel: "claude-sonnet-4-6", - createModelManagerOptions: (config: ModelManagerConfig) => opencodeZenModelManagerOptions(config), - envKeys: "OPENCODE_API_KEY", login: async (cb: OAuthLoginCallbacks) => { // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. const { loginOpenCode } = await import("./oauth/opencode"); diff --git a/packages/ai/src/registry/openrouter.ts b/packages/ai/src/registry/openrouter.ts index 8951c79f7..c01c76690 100644 --- a/packages/ai/src/registry/openrouter.ts +++ b/packages/ai/src/registry/openrouter.ts @@ -1,7 +1,6 @@ -import { openrouterModelManagerOptions } from "../provider-models/openai-compat"; import { createApiKeyLogin } from "./api-key-login"; import type { OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; /** OpenRouter login flow (API key paste, validated via /auth/key). * @@ -25,9 +24,5 @@ export const loginOpenRouter = createApiKeyLogin({ export const openrouterProvider = { id: "openrouter", name: "OpenRouter", - defaultModel: "openai/gpt-5.4", - createModelManagerOptions: (config: ModelManagerConfig) => openrouterModelManagerOptions(config), - catalogDiscovery: { label: "OpenRouter", envVars: ["OPENROUTER_API_KEY"], allowUnauthenticated: true }, - envKeys: "OPENROUTER_API_KEY", login: (cb: OAuthLoginCallbacks) => loginOpenRouter(cb), } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/qianfan.ts b/packages/ai/src/registry/qianfan.ts index 7d6a6a73c..71ba08129 100644 --- a/packages/ai/src/registry/qianfan.ts +++ b/packages/ai/src/registry/qianfan.ts @@ -1,7 +1,6 @@ -import { qianfanModelManagerOptions } from "../provider-models/openai-compat"; import { validateOpenAICompatibleApiKey } from "./api-key-validation"; import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; const AUTH_URL = "https://console.bce.baidu.com/qianfan/ais/console/apiKey"; const API_BASE_URL = "https://qianfan.baidubce.com/v2"; @@ -46,9 +45,5 @@ export async function loginQianfan(options: OAuthController): Promise { export const qianfanProvider = { id: "qianfan", name: "Qianfan", - defaultModel: "deepseek-v3.2", - createModelManagerOptions: (config: ModelManagerConfig) => qianfanModelManagerOptions(config), - catalogDiscovery: { label: "Qianfan", envVars: ["QIANFAN_API_KEY"] }, - envKeys: "QIANFAN_API_KEY", login: (cb: OAuthLoginCallbacks) => loginQianfan(cb), } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/qwen-portal.ts b/packages/ai/src/registry/qwen-portal.ts index f398962d9..d8ab82254 100644 --- a/packages/ai/src/registry/qwen-portal.ts +++ b/packages/ai/src/registry/qwen-portal.ts @@ -1,8 +1,6 @@ -import { $pickenv } from "@oh-my-pi/pi-utils"; -import { qwenPortalModelManagerOptions } from "../provider-models/openai-compat"; import { validateOpenAICompatibleApiKey } from "./api-key-validation"; import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; const AUTH_URL = "https://chat.qwen.ai"; const API_BASE_URL = "https://portal.qwen.ai/v1"; @@ -47,13 +45,5 @@ export async function loginQwenPortal(options: OAuthController): Promise export const qwenPortalProvider = { id: "qwen-portal", name: "Qwen Portal", - defaultModel: "coder-model", - createModelManagerOptions: (config: ModelManagerConfig) => qwenPortalModelManagerOptions(config), - catalogDiscovery: { - label: "Qwen Portal", - envVars: ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"], - oauthProvider: "qwen-portal", - }, - envKeys: () => $pickenv("QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"), login: (cb: OAuthLoginCallbacks) => loginQwenPortal(cb), } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/registry.ts b/packages/ai/src/registry/registry.ts index 4278996ee..e49787b77 100644 --- a/packages/ai/src/registry/registry.ts +++ b/packages/ai/src/registry/registry.ts @@ -1,3 +1,4 @@ +import type { KnownProvider } from "@oh-my-pi/pi-catalog"; import { aimlApiProvider } from "./aimlapi"; import { alibabaCodingPlanProvider } from "./alibaba-coding-plan"; import { amazonBedrockProvider } from "./amazon-bedrock"; @@ -137,7 +138,12 @@ export function getProviderDefinition(id: string): ProviderDefinition | undefine return BY_ID.get(id); } -/** Chat-model providers (those carrying a `defaultModel`). */ -export type KnownProviderId = Extract["id"]; +/** Compile-time completeness: every catalog chat-model provider must have a registry definition. */ +type _MissingCatalogProviders = Exclude; +type _CheckRegistryComplete = _MissingCatalogProviders extends never + ? true + : ["registry is missing catalog providers", _MissingCatalogProviders]; +true satisfies _CheckRegistryComplete; + /** Loginable providers (those carrying a `login` flow). */ export type OAuthProviderUnion = Extract["id"]; diff --git a/packages/ai/src/registry/synthetic.ts b/packages/ai/src/registry/synthetic.ts index 8e859911e..f891ff4f1 100644 --- a/packages/ai/src/registry/synthetic.ts +++ b/packages/ai/src/registry/synthetic.ts @@ -1,6 +1,5 @@ -import { syntheticModelManagerOptions } from "../provider-models/openai-compat"; import { createApiKeyLogin } from "./api-key-login"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; export const loginSynthetic = createApiKeyLogin({ providerLabel: "Synthetic", @@ -18,10 +17,5 @@ export const loginSynthetic = createApiKeyLogin({ export const syntheticProvider = { id: "synthetic", name: "Synthetic", - defaultModel: "hf:zai-org/GLM-5.1", - createModelManagerOptions: (config: ModelManagerConfig) => syntheticModelManagerOptions(config), - dynamicModelsAuthoritative: true, - catalogDiscovery: { label: "Synthetic", envVars: ["SYNTHETIC_API_KEY"] }, - envKeys: "SYNTHETIC_API_KEY", login: loginSynthetic, } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/together.ts b/packages/ai/src/registry/together.ts index 1b2c9ff6f..f6731300e 100644 --- a/packages/ai/src/registry/together.ts +++ b/packages/ai/src/registry/together.ts @@ -1,6 +1,5 @@ -import { togetherModelManagerOptions } from "../provider-models/openai-compat"; import { createApiKeyLogin } from "./api-key-login"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; export const loginTogether = createApiKeyLogin({ providerLabel: "Together", @@ -19,9 +18,5 @@ export const loginTogether = createApiKeyLogin({ export const togetherProvider = { id: "together", name: "Together", - defaultModel: "moonshotai/Kimi-K2.5", - createModelManagerOptions: (config: ModelManagerConfig) => togetherModelManagerOptions(config), - catalogDiscovery: { label: "Together", envVars: ["TOGETHER_API_KEY"] }, - envKeys: "TOGETHER_API_KEY", login: (cb: Parameters[0]) => loginTogether(cb), } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/types.ts b/packages/ai/src/registry/types.ts index 35467bccb..9b2838d22 100644 --- a/packages/ai/src/registry/types.ts +++ b/packages/ai/src/registry/types.ts @@ -1,20 +1,16 @@ /** - * Single-source provider model. Every provider — model providers, gateways, - * search/tool credentials, and login-only flows — is described by one - * {@link ProviderDefinition}. The legacy scattered structures (the - * `KnownProvider`/`OAuthProvider` unions, `PROVIDER_DESCRIPTORS`, - * `serviceProviderMap`, `builtInOAuthProviders`, the refresh/login switches, - * and the CLI callback maps) are all *derived* from the registry of these - * definitions. Adding a provider is one new file in `./providers/` plus one - * line in `./registry.ts`. + * Single-source provider auth model. Every provider — model providers, + * gateways, search/tool credentials, and login-only flows — is described by + * one {@link ProviderDefinition}. The legacy scattered structures (the + * `OAuthProvider` union, `serviceProviderMap`, `builtInOAuthProviders`, the + * refresh/login switches, and the CLI callback maps) are all *derived* from + * the registry of these definitions. Adding a provider is one new file in + * `./providers/` plus one line in `./registry.ts`. Model-catalog metadata + * (default model, model-manager factory, catalog discovery) lives in + * `@oh-my-pi/pi-catalog`'s descriptor table. */ -import type { ModelManagerOptions } from "../model-manager"; -import type { Api, FetchImpl } from "../types"; import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; -/** Config passed to a provider's runtime model-manager factory. */ -export type ModelManagerConfig = { apiKey?: string; baseUrl?: string; fetch?: FetchImpl }; - /** * API-key environment fallback: either a single env var name (e.g. * `"OPENAI_API_KEY"`) or a resolver that inspects several env vars / probes @@ -22,53 +18,13 @@ export type ModelManagerConfig = { apiKey?: string; baseUrl?: string; fetch?: Fe */ export type KeyResolver = string | (() => string | undefined); -/** Catalog discovery configuration for providers that support endpoint-based model listing. */ -export interface CatalogDiscoveryConfig { - /** Human-readable name for log messages. */ - label: string; - /** Environment variables to check for API keys during catalog generation. */ - envVars: readonly string[]; - /** OAuth provider for credential refresh during catalog generation. */ - oauthProvider?: string; - /** When true, catalog discovery proceeds even without credentials. */ - allowUnauthenticated?: boolean; -} - -/** Unified provider descriptor used by both runtime discovery and catalog generation. */ -export interface ProviderDescriptor { - providerId: string; - createModelManagerOptions(config: ModelManagerConfig): ModelManagerOptions; - /** Preferred model ID when no explicit selection is made. */ - defaultModel: string; - /** When true, the runtime creates a model manager even without a valid API key (e.g. ollama). */ - allowUnauthenticated?: boolean; - /** When true, successful runtime discovery replaces bundled provider models instead of merging fallback-only IDs. */ - dynamicModelsAuthoritative?: boolean; - /** Catalog discovery configuration. Only providers with this field participate in generate-models.ts. */ - catalogDiscovery?: CatalogDiscoveryConfig; -} - -/** A provider descriptor that has catalog discovery configured. */ -export type CatalogProviderDescriptor = ProviderDescriptor & { catalogDiscovery: CatalogDiscoveryConfig }; - -/** Type guard for descriptors with catalog discovery. */ -export function isCatalogDescriptor(d: ProviderDescriptor): d is CatalogProviderDescriptor { - return d.catalogDiscovery != null; -} - -/** Whether catalog discovery may run without provider credentials. */ -export function allowsUnauthenticatedCatalogDiscovery(descriptor: CatalogProviderDescriptor): boolean { - return descriptor.catalogDiscovery.allowUnauthenticated ?? descriptor.allowUnauthenticated ?? false; -} - /** - * Declarative description of a single provider. All fields are optional except - * `id`/`name`; presence of a field opts the provider into a derived structure: + * Declarative description of a single provider's auth/login wiring. All + * fields are optional except `id`/`name`; presence of a field opts the + * provider into a derived structure: * - * - `defaultModel` present ⇒ member of `KnownProvider` (a chat-model provider). - * - `createModelManagerOptions` present (and not `specialModelManager`) ⇒ - * appears in `PROVIDER_DESCRIPTORS` for runtime model discovery. - * - `envKeys` present ⇒ env-var fallback in `getEnvApiKey`. + * - `envKeys` present ⇒ env-var fallback in `getEnvApiKey`, overriding the + * catalog table's `envVars` for that provider. * - `login` present ⇒ member of `OAuthProvider`, shown in the `/login` list * (unless `showInLoginList === false`) and dispatchable via `AuthStorage.login`. * - `callbackPort` present ⇒ entry in the auth-broker `CALLBACK_PORTS` map. @@ -84,25 +40,7 @@ export interface ProviderDefinition { readonly available?: boolean; /** Whether to surface in the interactive login list. Defaults to true when `login` is present. */ readonly showInLoginList?: boolean; - // --- model discovery --- - /** Preferred model ID when no explicit selection is made. Presence ⇒ `KnownProvider` member. */ - readonly defaultModel?: string; - /** Runtime model-manager factory. Omitted for login-only tools and catalog-only providers. */ - readonly createModelManagerOptions?: (config: ModelManagerConfig) => ModelManagerOptions; - /** When true, the runtime creates a model manager even without a valid API key. */ - readonly allowUnauthenticated?: boolean; - /** When true, successful runtime discovery replaces bundled provider models. */ - readonly dynamicModelsAuthoritative?: boolean; - /** Catalog discovery configuration for generate-models.ts. */ - readonly catalogDiscovery?: CatalogDiscoveryConfig; - /** - * Providers whose model manager is constructed bespoke in the coding-agent - * runtime (`google-antigravity`/`google-gemini-cli`/`openai-codex`). Excluded - * from the derived `PROVIDER_DESCRIPTORS`; the registry supplies only their - * identity/login/refresh/default-model metadata. - */ - readonly specialModelManager?: boolean; - // --- env-var fallback --- + // --- env-var fallback (the catalog table's `envVars` supplies plain names; set this only for computed resolvers) --- readonly envKeys?: KeyResolver; // --- interactive login (OAuthProviderInterface-compatible) --- readonly login?: (callbacks: OAuthLoginCallbacks) => Promise; diff --git a/packages/ai/src/registry/venice.ts b/packages/ai/src/registry/venice.ts index f9fb3dee6..42878c20e 100644 --- a/packages/ai/src/registry/venice.ts +++ b/packages/ai/src/registry/venice.ts @@ -1,7 +1,6 @@ -import { veniceModelManagerOptions } from "../provider-models/openai-compat"; import { validateOpenAICompatibleApiKey } from "./api-key-validation"; import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; const AUTH_URL = "https://venice.ai/settings/api"; const API_BASE_URL = "https://api.venice.ai/api/v1"; @@ -52,9 +51,5 @@ export async function loginVenice(options: OAuthController): Promise { export const veniceProvider = { id: "venice", name: "Venice", - defaultModel: "llama-3.3-70b", - createModelManagerOptions: (config: ModelManagerConfig) => veniceModelManagerOptions(config), - catalogDiscovery: { label: "Venice", envVars: ["VENICE_API_KEY"], allowUnauthenticated: true }, - envKeys: "VENICE_API_KEY", login: (cb: OAuthLoginCallbacks) => loginVenice(cb), } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/vercel-ai-gateway.ts b/packages/ai/src/registry/vercel-ai-gateway.ts index 5c3e266b2..9f555e312 100644 --- a/packages/ai/src/registry/vercel-ai-gateway.ts +++ b/packages/ai/src/registry/vercel-ai-gateway.ts @@ -1,6 +1,5 @@ -import { vercelAiGatewayModelManagerOptions } from "../provider-models/openai-compat"; import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; const AUTH_URL = "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway%2Fapi-keys&title=AI+Gateway+API+Keys"; @@ -34,9 +33,5 @@ export async function loginVercelAiGateway(options: OAuthController): Promise vercelAiGatewayModelManagerOptions(config), - catalogDiscovery: { label: "Vercel AI Gateway", envVars: ["VERCEL_AI_GATEWAY_API_KEY"], allowUnauthenticated: true }, - envKeys: "AI_GATEWAY_API_KEY", login: (cb: OAuthLoginCallbacks) => loginVercelAiGateway(cb), } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/vllm.ts b/packages/ai/src/registry/vllm.ts index 3e175be77..1edb883c3 100644 --- a/packages/ai/src/registry/vllm.ts +++ b/packages/ai/src/registry/vllm.ts @@ -1,6 +1,5 @@ -import { vllmModelManagerOptions } from "../provider-models/openai-compat"; import type { OAuthController, OAuthLoginCallbacks, OAuthProvider } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; const PROVIDER_ID: OAuthProvider = "vllm"; const AUTH_URL = "https://docs.vllm.ai/en/latest/serving/openai_compatible_server.html"; @@ -30,9 +29,5 @@ export async function loginVllm(options: OAuthController): Promise { export const vllmProvider = { id: "vllm", name: "vLLM (Local OpenAI-compatible)", - defaultModel: "gpt-oss-20b", - createModelManagerOptions: (config: ModelManagerConfig) => vllmModelManagerOptions(config), - catalogDiscovery: { label: "vLLM", envVars: ["VLLM_API_KEY"], allowUnauthenticated: true }, - envKeys: "VLLM_API_KEY", login: (cb: OAuthLoginCallbacks) => loginVllm(cb), } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/wafer-pass.ts b/packages/ai/src/registry/wafer-pass.ts index 357b05f06..abbd0c3bb 100644 --- a/packages/ai/src/registry/wafer-pass.ts +++ b/packages/ai/src/registry/wafer-pass.ts @@ -1,14 +1,9 @@ -import { waferPassModelManagerOptions } from "../provider-models/openai-compat"; import type { OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; export const waferPassProvider = { id: "wafer-pass", name: "Wafer Pass (flat-rate subscription)", - defaultModel: "GLM-5.1", - createModelManagerOptions: (config: ModelManagerConfig) => waferPassModelManagerOptions(config), - catalogDiscovery: { label: "Wafer Pass", envVars: ["WAFER_PASS_API_KEY"], oauthProvider: "wafer-pass" }, - envKeys: "WAFER_PASS_API_KEY", login: async (cb: OAuthLoginCallbacks) => { // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. const { loginWaferPass } = await import("./oauth/wafer"); diff --git a/packages/ai/src/registry/wafer-serverless.ts b/packages/ai/src/registry/wafer-serverless.ts index 627c34f96..21163f0a9 100644 --- a/packages/ai/src/registry/wafer-serverless.ts +++ b/packages/ai/src/registry/wafer-serverless.ts @@ -1,18 +1,9 @@ -import { waferServerlessModelManagerOptions } from "../provider-models/openai-compat"; import type { OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; export const waferServerlessProvider = { id: "wafer-serverless", name: "Wafer Serverless (pay-as-you-go)", - defaultModel: "GLM-5.1", - createModelManagerOptions: (config: ModelManagerConfig) => waferServerlessModelManagerOptions(config), - catalogDiscovery: { - label: "Wafer Serverless", - envVars: ["WAFER_SERVERLESS_API_KEY"], - oauthProvider: "wafer-serverless", - }, - envKeys: "WAFER_SERVERLESS_API_KEY", login: async (cb: OAuthLoginCallbacks) => { // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. const { loginWaferServerless } = await import("./oauth/wafer"); diff --git a/packages/ai/src/registry/xai-oauth.ts b/packages/ai/src/registry/xai-oauth.ts index fe020b24e..bec1212d7 100644 --- a/packages/ai/src/registry/xai-oauth.ts +++ b/packages/ai/src/registry/xai-oauth.ts @@ -1,19 +1,9 @@ -import { $pickenv } from "@oh-my-pi/pi-utils"; -import { xaiOAuthModelManagerOptions } from "../provider-models/openai-compat"; import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; export const xaiOauthProvider = { id: "xai-oauth", name: "xAI Grok OAuth (SuperGrok Subscription)", - defaultModel: "grok-4.3", - createModelManagerOptions: (config: ModelManagerConfig) => xaiOAuthModelManagerOptions(config), - catalogDiscovery: { - label: "xAI Grok OAuth (SuperGrok)", - envVars: ["XAI_OAUTH_TOKEN", "XAI_API_KEY"], - oauthProvider: "xai-oauth", - }, - envKeys: () => $pickenv("XAI_OAUTH_TOKEN", "XAI_API_KEY"), login: async (cb: OAuthLoginCallbacks) => { // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. const { loginXAIOAuth } = await import("./oauth/xai-oauth"); diff --git a/packages/ai/src/registry/xai.ts b/packages/ai/src/registry/xai.ts index 1afc01545..538f9ec3e 100644 --- a/packages/ai/src/registry/xai.ts +++ b/packages/ai/src/registry/xai.ts @@ -1,10 +1,6 @@ -import { xaiModelManagerOptions } from "../provider-models/openai-compat"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; export const xaiProvider = { id: "xai", name: "xAI", - defaultModel: "grok-4-fast-non-reasoning", - createModelManagerOptions: (config: ModelManagerConfig) => xaiModelManagerOptions(config), - envKeys: "XAI_API_KEY", } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/xiaomi-token-plan-ams.ts b/packages/ai/src/registry/xiaomi-token-plan-ams.ts index bd1e13ad8..429308e6b 100644 --- a/packages/ai/src/registry/xiaomi-token-plan-ams.ts +++ b/packages/ai/src/registry/xiaomi-token-plan-ams.ts @@ -1,14 +1,9 @@ -import { xiaomiModelManagerOptions } from "../provider-models/openai-compat"; import type { OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; export const xiaomiTokenPlanAmsProvider = { id: "xiaomi-token-plan-ams", name: "Xiaomi Token Plan (Europe)", - defaultModel: "mimo-v2.5", - createModelManagerOptions: (config: ModelManagerConfig) => - xiaomiModelManagerOptions({ ...config, providerId: "xiaomi-token-plan-ams", tokenPlanRegion: "ams" }), - envKeys: "XIAOMI_TOKEN_PLAN_AMS_API_KEY", login: async (cb: OAuthLoginCallbacks) => { // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. const { loginXiaomiTokenPlan } = await import("./oauth/xiaomi"); diff --git a/packages/ai/src/registry/xiaomi-token-plan-cn.ts b/packages/ai/src/registry/xiaomi-token-plan-cn.ts index c0d4fcdf8..d7167ea8d 100644 --- a/packages/ai/src/registry/xiaomi-token-plan-cn.ts +++ b/packages/ai/src/registry/xiaomi-token-plan-cn.ts @@ -1,14 +1,9 @@ -import { xiaomiModelManagerOptions } from "../provider-models/openai-compat"; import type { OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; export const xiaomiTokenPlanCnProvider = { id: "xiaomi-token-plan-cn", name: "Xiaomi Token Plan (China)", - defaultModel: "mimo-v2.5", - createModelManagerOptions: (config: ModelManagerConfig) => - xiaomiModelManagerOptions({ ...config, providerId: "xiaomi-token-plan-cn", tokenPlanRegion: "cn" }), - envKeys: "XIAOMI_TOKEN_PLAN_CN_API_KEY", login: async (cb: OAuthLoginCallbacks) => { // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. const { loginXiaomiTokenPlan } = await import("./oauth/xiaomi"); diff --git a/packages/ai/src/registry/xiaomi-token-plan-sgp.ts b/packages/ai/src/registry/xiaomi-token-plan-sgp.ts index 63a9de7b6..7b692b98c 100644 --- a/packages/ai/src/registry/xiaomi-token-plan-sgp.ts +++ b/packages/ai/src/registry/xiaomi-token-plan-sgp.ts @@ -1,14 +1,9 @@ -import { xiaomiModelManagerOptions } from "../provider-models/openai-compat"; import type { OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; export const xiaomiTokenPlanSgpProvider = { id: "xiaomi-token-plan-sgp", name: "Xiaomi Token Plan (Singapore)", - defaultModel: "mimo-v2.5", - createModelManagerOptions: (config: ModelManagerConfig) => - xiaomiModelManagerOptions({ ...config, providerId: "xiaomi-token-plan-sgp", tokenPlanRegion: "sgp" }), - envKeys: "XIAOMI_TOKEN_PLAN_SGP_API_KEY", login: async (cb: OAuthLoginCallbacks) => { // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. const { loginXiaomiTokenPlan } = await import("./oauth/xiaomi"); diff --git a/packages/ai/src/registry/xiaomi.ts b/packages/ai/src/registry/xiaomi.ts index ea56a313e..17b955183 100644 --- a/packages/ai/src/registry/xiaomi.ts +++ b/packages/ai/src/registry/xiaomi.ts @@ -1,14 +1,9 @@ -import { xiaomiModelManagerOptions } from "../provider-models/openai-compat"; import type { OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; export const xiaomiProvider = { id: "xiaomi", name: "Xiaomi MiMo", - defaultModel: "mimo-v2-flash", - createModelManagerOptions: (config: ModelManagerConfig) => xiaomiModelManagerOptions(config), - catalogDiscovery: { label: "Xiaomi", envVars: ["XIAOMI_API_KEY"] }, - envKeys: "XIAOMI_API_KEY", login: async (cb: OAuthLoginCallbacks) => { // Lazy import: keep heavy OAuth flow modules out of the eager registry graph. const { loginXiaomi } = await import("./oauth/xiaomi"); diff --git a/packages/ai/src/registry/zai.ts b/packages/ai/src/registry/zai.ts index 641e0147b..307f06591 100644 --- a/packages/ai/src/registry/zai.ts +++ b/packages/ai/src/registry/zai.ts @@ -1,7 +1,6 @@ -import { zaiModelManagerOptions } from "../provider-models/special"; import { validateOpenAICompatibleApiKey } from "./api-key-validation"; import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; const AUTH_URL = "https://z.ai/manage-apikey/apikey-list"; const API_BASE_URL = "https://api.z.ai/api/coding/paas/v4"; @@ -45,9 +44,5 @@ export async function loginZai(options: OAuthController): Promise { export const zaiProvider = { id: "zai", name: "Z.AI (GLM Coding Plan)", - defaultModel: "glm-5.1", - createModelManagerOptions: (config: ModelManagerConfig) => zaiModelManagerOptions(config), - catalogDiscovery: { label: "zAI", envVars: ["ZAI_API_KEY"] }, - envKeys: "ZAI_API_KEY", login: (cb: OAuthLoginCallbacks) => loginZai(cb), } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/zenmux.ts b/packages/ai/src/registry/zenmux.ts index 233759b98..e40872d5a 100644 --- a/packages/ai/src/registry/zenmux.ts +++ b/packages/ai/src/registry/zenmux.ts @@ -1,7 +1,6 @@ -import { zenmuxModelManagerOptions } from "../provider-models/openai-compat"; import { createApiKeyLogin } from "./api-key-login"; import type { OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; export const loginZenMux = createApiKeyLogin({ providerLabel: "ZenMux", @@ -19,9 +18,5 @@ export const loginZenMux = createApiKeyLogin({ export const zenmuxProvider = { id: "zenmux", name: "ZenMux", - defaultModel: "anthropic/claude-opus-4.6", - createModelManagerOptions: (config: ModelManagerConfig) => zenmuxModelManagerOptions(config), - catalogDiscovery: { label: "ZenMux", envVars: ["ZENMUX_API_KEY"] }, - envKeys: "ZENMUX_API_KEY", login: (cb: OAuthLoginCallbacks) => loginZenMux(cb), } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/zhipu-coding-plan.ts b/packages/ai/src/registry/zhipu-coding-plan.ts index 566403888..b2ef099c6 100644 --- a/packages/ai/src/registry/zhipu-coding-plan.ts +++ b/packages/ai/src/registry/zhipu-coding-plan.ts @@ -1,7 +1,6 @@ -import { zhipuCodingPlanModelManagerOptions } from "../provider-models/openai-compat"; import { validateOpenAICompatibleApiKey } from "./api-key-validation"; import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; -import type { ModelManagerConfig, ProviderDefinition } from "./types"; +import type { ProviderDefinition } from "./types"; const AUTH_URL = "https://bigmodel.cn/coding-plan/personal/overview"; const API_BASE_URL = "https://open.bigmodel.cn/api/coding/paas/v4"; @@ -45,9 +44,5 @@ export async function loginZhipuCodingPlan(options: OAuthController): Promise zhipuCodingPlanModelManagerOptions(config), - catalogDiscovery: { label: "Zhipu Coding Plan", envVars: ["ZHIPU_API_KEY"] }, - envKeys: "ZHIPU_API_KEY", login: (cb: OAuthLoginCallbacks) => loginZhipuCodingPlan(cb), } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index e3ca37e57..15bc3eacf 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -1,13 +1,14 @@ -import { $env, extractHttpStatusFromError } from "@oh-my-pi/pi-utils"; -import { getCustomApi } from "./api-registry"; -import { AUTH_RETRY_STEPS, isApiKeyResolver, resolveRetryKey } from "./auth-retry"; -import type { Effort } from "./effort"; +import type { Effort } from "@oh-my-pi/pi-catalog/effort"; import { mapEffortToAnthropicAdaptiveEffort, mapEffortToGoogleThinkingLevel, modelOmitsReasoningEffort, requireSupportedEffort, -} from "./model-thinking"; +} from "@oh-my-pi/pi-catalog/model-thinking"; +import { CATALOG_PROVIDERS, type ProviderCatalogEntry } from "@oh-my-pi/pi-catalog/provider-models"; +import { $env, $pickenv, extractHttpStatusFromError } from "@oh-my-pi/pi-utils"; +import { getCustomApi } from "./api-registry"; +import { AUTH_RETRY_STEPS, isApiKeyResolver, resolveRetryKey } from "./auth-retry"; import type { BedrockOptions } from "./providers/amazon-bedrock"; import type { AnthropicOptions } from "./providers/anthropic"; import type { CursorOptions } from "./providers/cursor"; @@ -166,7 +167,20 @@ const LEGACY_ENV_KEYS: Record = { brave: "BRAVE_API_KEY", }; +/** + * Env fallbacks derived from the catalog table — the single source for plain + * provider env-var names. Registry defs override with computed resolvers + * (Foundry/ADC/Bedrock probes); legacy non-provider keys merge last. + */ +const CATALOG_ENTRY_ENV_KEYS = (CATALOG_PROVIDERS as readonly ProviderCatalogEntry[]).flatMap(provider => { + const envVars = provider.envVars; + if (!envVars || envVars.length === 0) return []; + const resolver: KeyResolver = envVars.length === 1 ? envVars[0] : () => $pickenv(...envVars); + return [[provider.id, resolver] as [string, KeyResolver]]; +}); + const serviceProviderMap: Record = { + ...Object.fromEntries(CATALOG_ENTRY_ENV_KEYS), ...Object.fromEntries( PROVIDER_REGISTRY.flatMap(provider => provider.envKeys != null ? [[provider.id, provider.envKeys] as [string, KeyResolver]] : [], diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 7270420f8..1aa43478d 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -1,9 +1,6 @@ -import type { ZodType, z } from "zod/v4"; -import type { ApiKey } from "./auth-retry"; -import type { BedrockOptions } from "./providers/amazon-bedrock"; -import type { AnthropicOptions } from "./providers/anthropic"; -import type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses"; -import type { CursorOptions } from "./providers/cursor"; +export * from "@oh-my-pi/pi-catalog/effort"; +export * from "@oh-my-pi/pi-catalog/types"; + import type { DeleteArgs, DeleteResult, @@ -20,7 +17,15 @@ import type { ShellResult, WriteArgs, WriteResult, -} from "./providers/cursor/gen/agent_pb"; +} from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb"; +import type { Effort } from "@oh-my-pi/pi-catalog/effort"; +import type { Api, FetchImpl, KnownApi, Model, Provider, ThinkingBudgets, Usage } from "@oh-my-pi/pi-catalog/types"; +import type { ZodType, z } from "zod/v4"; +import type { ApiKey } from "./auth-retry"; +import type { BedrockOptions } from "./providers/amazon-bedrock"; +import type { AnthropicOptions } from "./providers/anthropic"; +import type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses"; +import type { CursorOptions } from "./providers/cursor"; import type { GoogleOptions } from "./providers/google"; import type { GoogleGeminiCliOptions } from "./providers/google-gemini-cli"; import type { GoogleVertexOptions } from "./providers/google-vertex"; @@ -28,7 +33,6 @@ import type { OllamaChatOptions } from "./providers/ollama"; import type { OpenAICodexResponsesOptions } from "./providers/openai-codex-responses"; import type { OpenAICompletionsOptions } from "./providers/openai-completions"; import type { OpenAIResponsesOptions } from "./providers/openai-responses"; -import type { KnownProviderId } from "./registry"; import type { AssistantMessageEventStream } from "./utils/event-stream"; export type { AssistantMessageEventStream } from "./utils/event-stream"; @@ -46,19 +50,6 @@ export type { AssistantMessageEventStream } from "./utils/event-stream"; */ export const OPENAI_MAX_OUTPUT_TOKENS = 64000; -export type KnownApi = - | "openai-completions" - | "openai-responses" - | "openai-codex-responses" - | "azure-openai-responses" - | "anthropic-messages" - | "bedrock-converse-stream" - | "google-generative-ai" - | "google-gemini-cli" - | "google-vertex" - | "ollama-chat" - | "cursor-agent"; -export type Api = KnownApi | (string & {}); export interface ApiOptionsMap { "anthropic-messages": AnthropicOptions; "bedrock-converse-stream": BedrockOptions; @@ -84,44 +75,6 @@ export type OptionsForApi = | StreamOptions | (TApi extends keyof ApiOptionsMap ? ApiOptionsMap[TApi] : never); -/** Canonical thinking transport used by a model. */ -export type ThinkingControlMode = - | "effort" - | "budget" - | "google-level" - | "anthropic-adaptive" - | "anthropic-budget-effort"; - -/** Per-model thinking capabilities used to clamp and map user-facing effort levels. */ -export interface ThinkingConfig { - /** Least intensive supported user-facing effort level. */ - minLevel: Effort; - /** Most intensive supported user-facing effort level. */ - maxLevel: Effort; - /** - * Optional explicit list of supported levels. When present, takes precedence over - * the `minLevel`..`maxLevel` range — used to encode discrete sets with gaps - * (e.g. Gemini 3 Pro supports `low` and `high` but not `medium`). - */ - levels?: readonly Effort[]; - /** Optional default effort applied when this model is selected. Falls back to global default if absent. */ - defaultLevel?: Effort; - /** Provider-specific transport used to encode the selected effort. */ - mode: ThinkingControlMode; -} - -export type KnownProvider = KnownProviderId; -// `Provider` is any provider-id string; `KnownProvider` enumerates the built-in model -// providers. Kept structurally `string` (the prior `KnownProvider | string` already -// collapsed to `string`) so the registry-derived `KnownProvider` can reference the model -// types below without forming a circular type-alias reference. -export type Provider = string; - -import type { Effort } from "./effort"; - -/** Token budgets for each thinking level (token-based providers only) */ -export type ThinkingBudgets = { [key in Effort]?: number }; - export interface TokenTaskBudget { type: "tokens"; total: number; @@ -233,15 +186,6 @@ export interface RawSseEvent { raw: string[]; } -/** - * `fetch`-compatible function. Accepts any callable matching the standard - * fetch signature; `preconnect` is optional because non-Bun runtimes (browsers, - * test mocks) won't expose it. - */ -export type FetchImpl = ((input: string | URL | Request, init?: RequestInit) => Promise) & { - preconnect?: typeof globalThis.fetch.preconnect; -}; - export interface StreamOptions { temperature?: number; topP?: number; @@ -484,53 +428,6 @@ export interface ToolCall { customWireName?: string; } -export interface Usage { - /** Non-cached input tokens (matches the bucket the provider bills as new input). */ - input: number; - /** Total output tokens for the turn, including thinking, assistant text, and tool-call argument tokens. */ - output: number; - /** Tokens read from the prompt cache. */ - cacheRead: number; - /** Tokens written to the prompt cache (cache creation). */ - cacheWrite: number; - /** Sum of input + output + cacheRead + cacheWrite. */ - totalTokens: number; - /** Copilot premium-request counter, when applicable. */ - premiumRequests?: number; - /** - * Reasoning/thinking tokens included in `output`, when the provider reports them - * (OpenAI `output_tokens_details.reasoning_tokens`, Google `thoughtsTokenCount`). - * Always a subset of `output` — non-reasoning output is `output - reasoningTokens`. - * - * Providers that don't expose this leave it undefined rather than guessing; - * `undefined` means unknown, NOT zero. - */ - reasoningTokens?: number; - /** - * Cache-write TTL breakdown (Anthropic only). When set, the components sum to - * `cacheWrite`. Absent providers do not populate this. - */ - cttl?: { - ephemeral5m?: number; - ephemeral1h?: number; - }; - /** - * Server-side tool invocations made during this turn (Anthropic web_search / - * web_fetch, OpenAI built-in tools when reported). Counts requests, not tokens. - */ - server?: { - webSearch?: number; - webFetch?: number; - }; - cost: { - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - total: number; - }; -} - export type StopReason = "stop" | "length" | "toolUse" | "error" | "aborted"; export interface OpenAIResponsesHistoryPayload { @@ -724,227 +621,3 @@ export type AssistantMessageEvent = reason: Extract; error: AssistantMessage; }; - -/** - * Compatibility settings for openai-completions API. - * Use this to override URL-based auto-detection for custom providers. - */ -export interface OpenAICompat { - /** Whether the provider supports the `store` field. Default: auto-detected from URL. */ - supportsStore?: boolean; - /** Whether the provider supports the `developer` role (vs `system`). Default: auto-detected from URL. */ - supportsDeveloperRole?: boolean; - /** - * Whether the provider's chat-completions endpoint accepts multiple - * leading `system`/`developer` messages. When false, ordered system - * prompts are coalesced into a single message joined by `\n\n` so - * strict chat templates (e.g. Qwen-served via vLLM, MiniMax) accept - * the request. Default: detected per provider/baseUrl. Canonical - * OpenAI/Azure/OpenRouter/Cerebras/Together/Fireworks/Groq/DeepSeek/ - * Mistral/xAI/Z.ai/GitHub Copilot/Zenmux are treated as `true`; - * unknown or strict-template hosts default to `false`. Setting this - * to `true` preserves separate blocks, which is preferred for - * KV-cache reuse when the trailing prompt changes between calls. - */ - supportsMultipleSystemMessages?: boolean; - /** Whether the provider supports `reasoning_effort`. Default: auto-detected from URL. */ - supportsReasoningEffort?: boolean; - /** Optional mapping from pi-ai reasoning levels to provider/model-specific `reasoning_effort` values. */ - reasoningEffortMap?: Partial>; - /** Whether the provider supports `stream_options: { include_usage: true }` for token usage in streaming responses. Default: true. */ - supportsUsageInStreaming?: boolean; - /** Which field to use for max tokens. Default: auto-detected from URL. */ - maxTokensField?: "max_completion_tokens" | "max_tokens"; - /** Whether tool results require the `name` field. Default: auto-detected from URL. */ - requiresToolResultName?: boolean; - /** Whether a user message after tool results requires an assistant message in between. Default: auto-detected from URL. */ - requiresAssistantAfterToolResult?: boolean; - /** Whether thinking blocks must be converted to text blocks with delimiters. Default: auto-detected from URL. */ - requiresThinkingAsText?: boolean; - /** Whether tool call IDs must be normalized to Mistral format (exactly 9 alphanumeric chars). Default: auto-detected from URL. */ - requiresMistralToolIds?: boolean; - /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "zai" uses thinking: { type: "enabled" | "disabled" } (also used by Moonshot Kimi), "qwen" uses top-level enable_thinking, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */ - thinkingFormat?: "openai" | "openrouter" | "zai" | "qwen" | "qwen-chat-template"; - /** Optional `thinking.keep` value for Z.ai/Moonshot-style thinking params. Set false to suppress auto-detected keep. Default: auto-detected. */ - thinkingKeep?: "all" | false; - /** Which reasoning content field to emit on assistant messages. Default: auto-detected. */ - reasoningContentField?: "reasoning_content" | "reasoning" | "reasoning_text"; - /** Whether assistant tool-call messages must include reasoning content. Default: false. */ - requiresReasoningContentForToolCalls?: boolean; - /** Whether the provider accepts a synthetic placeholder (e.g. ".") for missing reasoning_content on tool-call turns. Default: true. Set to false for providers like DeepSeek that validate the exact reasoning_content value. */ - allowsSyntheticReasoningContentForToolCalls?: boolean; - /** Whether assistant tool-call messages must include non-empty content. Default: false. */ - requiresAssistantContentForToolCalls?: boolean; - /** Whether the provider supports the `tool_choice` parameter. Default: true. */ - supportsToolChoice?: boolean; - /** - * Drop reasoning fields (`reasoning_effort`, OpenRouter `reasoning`) for - * the request when `tool_choice` forces a tool call. Mirrors the Anthropic - * `disableThinkingIfToolChoiceForced` rule for backends like Kimi that - * 400 with `tool_choice 'specified' is incompatible with thinking - * enabled` whenever both are present. Default: auto-detected (Kimi). - */ - disableReasoningOnForcedToolChoice?: boolean; - /** - * Drop reasoning fields (`reasoning_effort`, OpenRouter `reasoning`) for - * any request that sends `tool_choice`. Use for providers/models that accept - * tools and `tool_choice`, but reject `tool_choice` while thinking is enabled. - * Default: auto-detected (DeepSeek reasoning models). - */ - disableReasoningOnToolChoice?: boolean; - /** OpenRouter-specific routing preferences. Only used when baseUrl points to OpenRouter. */ - openRouterRouting?: OpenRouterRouting; - /** Vercel AI Gateway routing preferences. Only used when baseUrl points to Vercel AI Gateway. */ - vercelGatewayRouting?: VercelGatewayRouting; - /** Extra fields to include in request body (e.g. gateway routing hints for OpenClaw-style proxies). */ - extraBody?: Record; - /** Whether chat-completions payloads should include provider-specific prompt-cache markers. */ - cacheControlFormat?: "anthropic" | undefined; - /** Whether the provider supports the `strict` field in tool definitions. Default: auto-detected per provider/baseUrl (conservative for unknown providers). */ - supportsStrictMode?: boolean; - /** Whether tool schemas must be sent either all strict or all non-strict. Undefined keeps the existing per-tool mixed behavior. */ - toolStrictMode?: "all_strict" | "none"; -} - -/** - * Compatibility settings for anthropic-messages API. - * Use this to disable features that strict-by-default Anthropic accepts but - * that proxy gateways (Vertex AI, AWS Bedrock-style fronts, etc.) reject. - */ -export interface AnthropicCompat { - /** - * Drop the top-level `strict: true` field on tool definitions. Vertex AI's - * Anthropic-compatible endpoint rejects unknown tool fields with - * `tools..custom.strict: Extra inputs are not permitted`. - */ - disableStrictTools?: boolean; - /** - * Map adaptive thinking (`thinking: { type: "adaptive" }`) to - * `{ type: "enabled", budget_tokens }`. Vertex AI rejects the `adaptive` - * tag with `Input tag 'adaptive' ... does not match any of the expected - * tags: 'disabled', 'enabled'`. - */ - disableAdaptiveThinking?: boolean; - /** Whether tools may include Anthropic's per-tool eager_input_streaming flag. Default: true. */ - supportsEagerToolInputStreaming?: boolean; - /** Whether long prompt-cache retention (`ttl: "1h"`) is supported. Default: true for canonical Anthropic API. */ - supportsLongCacheRetention?: boolean; - /** - * Whether mid-conversation `role: "system"` messages are accepted in the - * `messages` array (Claude Opus 4.8+ and Claude Fable/Mythos 5 on the - * first-party Claude API and Claude Platform on AWS). When unset, - * auto-detected from the model id and base URL. Not available on Bedrock, - * Vertex AI, or Microsoft Foundry. - */ - supportsMidConversationSystem?: boolean; - /** - * Whether the model accepts a forced `tool_choice` (`{ type: "any" }` or - * `{ type: "tool", name }`). Claude Fable/Mythos 5 reject forced tool use - * outright ("tool_choice forces tool use is not compatible with this model"); - * the request builder downgrades forced choices to `auto` when this is false. - * When unset, auto-detected from the model id. Default: true. - */ - supportsForcedToolChoice?: boolean; -} - -/** - * OpenRouter provider routing preferences. - * Controls which upstream providers OpenRouter routes requests to. - * @see https://openrouter.ai/docs/provider-routing - */ -export interface OpenRouterRouting { - /** List of provider slugs to exclusively use for this request (e.g., ["amazon-bedrock", "anthropic"]). */ - only?: string[]; - /** List of provider slugs to try in order (e.g., ["anthropic", "openai"]). */ - order?: string[]; -} - -/** - * Vercel AI Gateway routing preferences. - * Controls which upstream providers the gateway routes requests to. - * @see https://vercel.com/docs/ai-gateway/models-and-providers/provider-options - */ -export interface VercelGatewayRouting { - /** List of provider slugs to exclusively use for this request (e.g., ["bedrock", "anthropic"]). */ - only?: string[]; - /** List of provider slugs to try in order (e.g., ["anthropic", "openai"]). */ - order?: string[]; -} - -// Model interface for the unified model system -export interface Model { - id: string; - name: string; - api: TApi; - provider: Provider; - baseUrl: string; - reasoning: boolean; - input: ("text" | "image")[]; - cost: { - input: number; // $/million tokens - output: number; // $/million tokens - cacheRead: number; // $/million tokens - cacheWrite: number; // $/million tokens - }; - /** Premium Copilot requests charged per user-initiated request (defaults to 1). */ - premiumMultiplier?: number; - contextWindow: number; - maxTokens: number; - /** - * When `true`, providers MUST omit `max_output_tokens` (Responses) / - * `max_tokens` / `max_completion_tokens` (Completions) from the outbound - * request and let the upstream API decide the per-response cap. `maxTokens` - * is still used locally for budgeting (compaction, context promotion); only - * the wire field is suppressed. - * - * Use this for proxies (notably Ollama) that forward to a backend whose true - * output limit OMP cannot discover — sending the wrong value triggers 400s - * from the upstream provider. - */ - omitMaxOutputTokens?: boolean; - headers?: Record; - /** - * Streaming transport override. When `"pi-native"`, `streamSimple` routes - * the request to the model's `baseUrl` via the auth-gateway's - * `POST /v1/pi/stream` endpoint instead of dispatching the per-API - * provider client. The `baseUrl` must point at an `omp auth-gateway` - * (or compatible) host; `headers.Authorization` (or `apiKey` resolved by - * the registry) carries the gateway bearer. - * - * Used by containerized omp installs (e.g. robomp slots) to route every - * LLM call through a sidecar gateway that holds the real provider - * credentials. The model's other metadata (pricing, context window, - * thinking config, …) still resolves locally; only the streaming - * dispatch is redirected. - */ - transport?: "pi-native"; - /** Hint that websocket transport should be preferred when supported by the provider implementation. */ - preferWebsockets?: boolean; - /** Preferred model to switch to when context promotion is triggered (model id or provider/id). */ - contextPromotionTarget?: string; - /** Provider-assigned priority value (lower = higher priority). */ - priority?: number; - /** Canonical thinking capability metadata for this model. */ - thinking?: ThinkingConfig; - /** Compatibility overrides per API. If not set, auto-detected from baseUrl. */ - compat?: TApi extends "openai-completions" | "openai-responses" - ? OpenAICompat - : TApi extends "anthropic-messages" - ? AnthropicCompat - : never; - /** - * Which shape to use when exposing the Codex `apply_patch` tool to this model. - * Generated catalog policy sets `"freeform"` for first-party GPT-5 Responses - * models that support OpenAI custom tools with a Lark grammar. The freeform - * variant sends a raw patch string with no JSON envelope. - * - `"function"` or undefined: JSON function-tool with `{input: string}` (spec §1.2). - */ - applyPatchToolType?: "freeform" | "function"; - /** - * Force OAuth-style request shaping for providers whose API key prefix doesn't - * match an OAuth token (e.g. routing Anthropic traffic through a proxy that - * expects Claude Code framing). When true, the streaming layer sets - * `options.isOAuth = true` for the underlying provider call. - */ - isOAuth?: boolean; -} diff --git a/packages/ai/src/usage/claude.ts b/packages/ai/src/usage/claude.ts index a17b2f9ed..8a5003f60 100644 --- a/packages/ai/src/usage/claude.ts +++ b/packages/ai/src/usage/claude.ts @@ -1,4 +1,5 @@ import { scheduler } from "node:timers/promises"; +import { toNumber } from "@oh-my-pi/pi-catalog/utils"; import { claudeCodeVersion } from "../providers/anthropic"; import type { CredentialRankingStrategy, @@ -11,7 +12,7 @@ import type { UsageStatus, UsageWindow, } from "../usage"; -import { isRecord, toNumber } from "../utils"; +import { isRecord } from "../utils"; const DEFAULT_ENDPOINT = "https://api.anthropic.com/api/oauth"; const FIVE_HOURS_MS = 5 * 60 * 60 * 1000; diff --git a/packages/ai/src/usage/github-copilot.ts b/packages/ai/src/usage/github-copilot.ts index a810ebb06..c15e432bc 100644 --- a/packages/ai/src/usage/github-copilot.ts +++ b/packages/ai/src/usage/github-copilot.ts @@ -4,7 +4,8 @@ * Normalizes Copilot quota usage into the shared UsageReport schema. */ -import { OPENCODE_HEADERS } from "../registry/oauth/github-copilot"; +import { toBoolean, toNumber } from "@oh-my-pi/pi-catalog/utils"; +import { OPENCODE_HEADERS } from "@oh-my-pi/pi-catalog/wire/github-copilot"; import type { UsageAmount, UsageFetchContext, @@ -15,7 +16,7 @@ import type { UsageStatus, UsageWindow, } from "../usage"; -import { isRecord, toBoolean, toNumber } from "../utils"; +import { isRecord } from "../utils"; type CopilotQuotaDetail = { entitlement: number; diff --git a/packages/ai/src/usage/google-antigravity.ts b/packages/ai/src/usage/google-antigravity.ts index 435e039a4..5e935a075 100644 --- a/packages/ai/src/usage/google-antigravity.ts +++ b/packages/ai/src/usage/google-antigravity.ts @@ -1,4 +1,4 @@ -import { getAntigravityUserAgent } from "../providers/google-gemini-headers"; +import { getAntigravityUserAgent } from "@oh-my-pi/pi-catalog/wire/gemini-headers"; import type { CredentialRankingStrategy, UsageAmount, diff --git a/packages/ai/src/usage/openai-codex.ts b/packages/ai/src/usage/openai-codex.ts index b4ff37f3b..20fe97669 100644 --- a/packages/ai/src/usage/openai-codex.ts +++ b/packages/ai/src/usage/openai-codex.ts @@ -1,5 +1,5 @@ import { Buffer } from "node:buffer"; -import { CODEX_BASE_URL } from "../providers/openai-codex/constants"; +import { CODEX_BASE_URL } from "@oh-my-pi/pi-catalog/wire/codex"; import type { CredentialRankingStrategy, UsageAmount, diff --git a/packages/ai/src/usage/zai.ts b/packages/ai/src/usage/zai.ts index a2fdf6d34..47fd5bf38 100644 --- a/packages/ai/src/usage/zai.ts +++ b/packages/ai/src/usage/zai.ts @@ -1,3 +1,4 @@ +import { toNumber } from "@oh-my-pi/pi-catalog/utils"; import type { UsageAmount, UsageFetchContext, @@ -8,7 +9,7 @@ import type { UsageStatus, UsageWindow, } from "../usage"; -import { isRecord, toNumber } from "../utils"; +import { isRecord } from "../utils"; const DEFAULT_ENDPOINT = "https://api.z.ai"; const QUOTA_PATH = "/api/monitor/usage/quota/limit"; diff --git a/packages/ai/src/utils.ts b/packages/ai/src/utils.ts index e6e3bc74f..d4c44c9a5 100644 --- a/packages/ai/src/utils.ts +++ b/packages/ai/src/utils.ts @@ -11,26 +11,6 @@ export function normalizeSystemPrompts(systemPrompt: readonly string[] | string return prompts.map(prompt => prompt.toWellFormed()).filter(prompt => prompt.trim().length > 0); } -export function toNumber(value: unknown): number | undefined { - if (typeof value === "number" && Number.isFinite(value)) return value; - if (typeof value === "string" && value.trim()) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : undefined; - } - return undefined; -} - -export function toPositiveNumber(value: unknown, fallback: number): number { - if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { - return fallback; - } - return value; -} - -export function toBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} - export function normalizeToolCallId(id: string): string { const sanitized = id.replace(/[^a-zA-Z0-9_-]/g, "_"); return sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized; @@ -160,7 +140,3 @@ export function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRet if ($env.PI_CACHE_RETENTION === "long") return "long"; return "short"; } - -export function isAnthropicOAuthToken(key: string): boolean { - return key.includes("sk-ant-oat"); -} diff --git a/packages/ai/test/abort.test.ts b/packages/ai/test/abort.test.ts index b4d2a1755..77fd6d9b9 100644 --- a/packages/ai/test/abort.test.ts +++ b/packages/ai/test/abort.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { complete, stream } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, Model, OptionsForApi } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { e2eApiKey, resolveApiKey } from "./oauth"; // Resolve OAuth tokens at module level (async, runs before tests) diff --git a/packages/ai/test/anthropic-fable-request-shaping.test.ts b/packages/ai/test/anthropic-fable-request-shaping.test.ts index fa65fd833..b844303b3 100644 --- a/packages/ai/test/anthropic-fable-request-shaping.test.ts +++ b/packages/ai/test/anthropic-fable-request-shaping.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { Effort } from "@oh-my-pi/pi-ai/effort"; import { streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic"; import type { Context, Model } from "@oh-my-pi/pi-ai/types"; +import { Effort } from "@oh-my-pi/pi-catalog/effort"; function makeAnthropicModel(id: string): Model<"anthropic-messages"> { return { diff --git a/packages/ai/test/auth-gateway-openai-responses.test.ts b/packages/ai/test/auth-gateway-openai-responses.test.ts index d002caf86..4c50ccbcc 100644 --- a/packages/ai/test/auth-gateway-openai-responses.test.ts +++ b/packages/ai/test/auth-gateway-openai-responses.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "bun:test"; -import { Effort } from "@oh-my-pi/pi-ai/effort"; import { encodeResponse, encodeStream, parseRequest } from "@oh-my-pi/pi-ai/providers/openai-responses-server"; import type { AssistantMessage } from "@oh-my-pi/pi-ai/types"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; +import { Effort } from "@oh-my-pi/pi-catalog/effort"; function zeroUsage(): AssistantMessage["usage"] { return { diff --git a/packages/ai/test/auth-gateway-pi-native.test.ts b/packages/ai/test/auth-gateway-pi-native.test.ts index 38e2d9ab9..143ca1d6f 100644 --- a/packages/ai/test/auth-gateway-pi-native.test.ts +++ b/packages/ai/test/auth-gateway-pi-native.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from "bun:test"; -import { Effort } from "@oh-my-pi/pi-ai/effort"; import { encodeStream, formatError, parseRequest } from "@oh-my-pi/pi-ai/providers/pi-native-server"; import type { AssistantMessage, @@ -8,6 +7,7 @@ import type { Context, Usage, } from "@oh-my-pi/pi-ai/types"; +import { Effort } from "@oh-my-pi/pi-catalog/effort"; function makeEventStream(events: AssistantMessageEvent[], final: AssistantMessage): AssistantMessageEventStream { async function* iter() { diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index 3312d5ff5..7aad9b774 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -14,10 +14,10 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import type { ChildProcess } from "node:child_process"; import { execSync, spawn } from "node:child_process"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { complete } from "@oh-my-pi/pi-ai/stream"; import type { AssistantMessage, Context, Model, Usage } from "@oh-my-pi/pi-ai/types"; import { isContextOverflow } from "@oh-my-pi/pi-ai/utils/overflow"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { $which } from "@oh-my-pi/pi-utils"; import { e2eApiKey, resolveApiKey } from "./oauth"; diff --git a/packages/ai/test/cursor-exec-handlers.test.ts b/packages/ai/test/cursor-exec-handlers.test.ts index a11c9563c..3735d811d 100644 --- a/packages/ai/test/cursor-exec-handlers.test.ts +++ b/packages/ai/test/cursor-exec-handlers.test.ts @@ -5,8 +5,8 @@ import { resolveExecHandler, streamCursor, } from "@oh-my-pi/pi-ai/providers/cursor"; -import type { AgentRunRequest } from "@oh-my-pi/pi-ai/providers/cursor/gen/agent_pb"; import type { Context, Model } from "@oh-my-pi/pi-ai/types"; +import type { AgentRunRequest } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb"; const cursorModel: Model<"cursor-agent"> = { id: "cursor-composer-2.5", diff --git a/packages/ai/test/deepseek-reasoning-content.test.ts b/packages/ai/test/deepseek-reasoning-content.test.ts index b8aebf8cf..bbee3e754 100644 --- a/packages/ai/test/deepseek-reasoning-content.test.ts +++ b/packages/ai/test/deepseek-reasoning-content.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { convertMessages, detectCompat } from "@oh-my-pi/pi-ai/providers/openai-completions"; import type { AssistantMessage, Model, ThinkingContent, ToolCall } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; function deepseekModel(overrides: Partial>): Model<"openai-completions"> { return { diff --git a/packages/ai/test/firepass.live.ts b/packages/ai/test/firepass.live.ts index c1657b440..496366f1a 100644 --- a/packages/ai/test/firepass.live.ts +++ b/packages/ai/test/firepass.live.ts @@ -8,9 +8,10 @@ * 2. The PR #1199 P2 fix (xhigh → max) actually clears the wire — without * the mapping Fireworks 400s the request. */ -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; + import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import type { Context, FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; const apiKey = process.env.FIREPASS_API_KEY; if (!apiKey) { diff --git a/packages/ai/test/firepass.test.ts b/packages/ai/test/firepass.test.ts index 15de3f7d1..995eda884 100644 --- a/packages/ai/test/firepass.test.ts +++ b/packages/ai/test/firepass.test.ts @@ -7,9 +7,9 @@ * form at request time. */ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import type { Context, FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; function sseResponse(events: unknown[]): Response { const payload = `${events.map(e => `data: ${typeof e === "string" ? e : JSON.stringify(e)}`).join("\n\n")}\n\n`; diff --git a/packages/ai/test/github-copilot-anthropic-auth.test.ts b/packages/ai/test/github-copilot-anthropic-auth.test.ts index f22839f4a..1b2751433 100644 --- a/packages/ai/test/github-copilot-anthropic-auth.test.ts +++ b/packages/ai/test/github-copilot-anthropic-auth.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import { buildAnthropicClientOptions, streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic"; -import { OPENCODE_HEADERS } from "@oh-my-pi/pi-ai/registry/oauth/github-copilot"; import type { Context, Model } from "@oh-my-pi/pi-ai/types"; import { buildAnthropicUrl } from "@oh-my-pi/pi-ai/utils/anthropic-auth"; +import { OPENCODE_HEADERS } from "@oh-my-pi/pi-catalog/wire/github-copilot"; afterEach(() => { vi.restoreAllMocks(); diff --git a/packages/ai/test/github-copilot-headers.test.ts b/packages/ai/test/github-copilot-headers.test.ts index f293a50f6..2d3f8ec04 100644 --- a/packages/ai/test/github-copilot-headers.test.ts +++ b/packages/ai/test/github-copilot-headers.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { buildCopilotDynamicHeaders, getCopilotInitiatorOverride, @@ -8,6 +7,7 @@ import { inferCopilotInitiator, } from "@oh-my-pi/pi-ai/providers/github-copilot-headers"; import type { Message } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; describe("inferCopilotInitiator", () => { it("returns 'user' when there are no messages", () => { diff --git a/packages/ai/test/github-copilot-openai-base-url.test.ts b/packages/ai/test/github-copilot-openai-base-url.test.ts index 6d3aeec69..62c6e229e 100644 --- a/packages/ai/test/github-copilot-openai-base-url.test.ts +++ b/packages/ai/test/github-copilot-openai-base-url.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; import type { Context, Model } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; afterEach(() => { vi.restoreAllMocks(); diff --git a/packages/ai/test/github-copilot-reasoning.test.ts b/packages/ai/test/github-copilot-reasoning.test.ts index c8b5adac8..6360c40dd 100644 --- a/packages/ai/test/github-copilot-reasoning.test.ts +++ b/packages/ai/test/github-copilot-reasoning.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { Effort } from "@oh-my-pi/pi-ai/effort"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic"; import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; import type { Context, Model } from "@oh-my-pi/pi-ai/types"; +import { Effort } from "@oh-my-pi/pi-catalog/effort"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; const testContext: Context = { messages: [{ role: "user", content: "hello", timestamp: Date.now() }], diff --git a/packages/ai/test/google-gemini-cli-3x-thinking.test.ts b/packages/ai/test/google-gemini-cli-3x-thinking.test.ts index 1b28ce5d7..a46f323a1 100644 --- a/packages/ai/test/google-gemini-cli-3x-thinking.test.ts +++ b/packages/ai/test/google-gemini-cli-3x-thinking.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "bun:test"; import { Effort, type FetchImpl } from "@oh-my-pi/pi-ai"; -import { enrichModelThinking } from "@oh-my-pi/pi-ai/model-thinking"; import { streamSimple } from "@oh-my-pi/pi-ai/stream"; import type { Context, Model } from "@oh-my-pi/pi-ai/types"; +import { enrichModelThinking } from "@oh-my-pi/pi-catalog/model-thinking"; interface GeminiCliThinkingConfig { thinkingLevel?: string; diff --git a/packages/ai/test/google-tool-choice.test.ts b/packages/ai/test/google-tool-choice.test.ts index aeac29875..8697325c3 100644 --- a/packages/ai/test/google-tool-choice.test.ts +++ b/packages/ai/test/google-tool-choice.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { buildGoogleGenerateContentParams } from "@oh-my-pi/pi-ai/providers/google-shared"; import { mapGoogleToolChoice } from "@oh-my-pi/pi-ai/stream"; import type { Context, Tool, ToolChoice } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; describe("mapGoogleToolChoice (F7)", () => { it("returns string passthrough for auto/none/any", () => { diff --git a/packages/ai/test/handoff.test.ts b/packages/ai/test/handoff.test.ts index 8745b6b4f..57043963d 100644 --- a/packages/ai/test/handoff.test.ts +++ b/packages/ai/test/handoff.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { complete } from "@oh-my-pi/pi-ai/stream"; import type { Api, AssistantMessage, Context, Message, Model, Tool, ToolResultMessage } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import * as z from "zod/v4"; import { e2eApiKey } from "./oauth"; diff --git a/packages/ai/test/helpers/index.ts b/packages/ai/test/helpers/index.ts index 700f673b2..e0bb4d7e7 100644 --- a/packages/ai/test/helpers/index.ts +++ b/packages/ai/test/helpers/index.ts @@ -1,7 +1,7 @@ import * as os from "node:os"; import * as path from "node:path"; -import { enrichModelThinking } from "@oh-my-pi/pi-ai/model-thinking"; import type { Model } from "@oh-my-pi/pi-ai/types"; +import { enrichModelThinking } from "@oh-my-pi/pi-catalog/model-thinking"; import { isEnoent } from "@oh-my-pi/pi-utils"; export async function withEnv( diff --git a/packages/ai/test/image-limits.test.ts b/packages/ai/test/image-limits.test.ts index a955b93be..be852cee0 100644 --- a/packages/ai/test/image-limits.test.ts +++ b/packages/ai/test/image-limits.test.ts @@ -71,9 +71,9 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { execSync } from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { complete } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, ImageContent, Model, OptionsForApi, UserMessage } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { $which } from "@oh-my-pi/pi-utils"; import { e2eApiKey } from "./oauth"; diff --git a/packages/ai/test/image-tool-result.test.ts b/packages/ai/test/image-tool-result.test.ts index 2e25e23ba..ffd87da98 100644 --- a/packages/ai/test/image-tool-result.test.ts +++ b/packages/ai/test/image-tool-result.test.ts @@ -2,8 +2,9 @@ import { describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import type { Api, Context, Model, Tool, ToolResultMessage } from "@oh-my-pi/pi-ai"; -import { complete, getBundledModel } from "@oh-my-pi/pi-ai"; +import { complete } from "@oh-my-pi/pi-ai"; import type { OptionsForApi } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import * as z from "zod/v4"; import { e2eApiKey, resolveApiKey } from "./oauth"; diff --git a/packages/ai/test/issue-1203-repro.test.ts b/packages/ai/test/issue-1203-repro.test.ts index 25cbcc0f8..930af2bd0 100644 --- a/packages/ai/test/issue-1203-repro.test.ts +++ b/packages/ai/test/issue-1203-repro.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import type { Context, FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; function createSseResponse(events: unknown[]): Response { const payload = `${events diff --git a/packages/ai/test/issue-1207-repro.test.ts b/packages/ai/test/issue-1207-repro.test.ts index d71c91b3d..b983b41ae 100644 --- a/packages/ai/test/issue-1207-repro.test.ts +++ b/packages/ai/test/issue-1207-repro.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; -import { detectOpenAICompat, resolveOpenAICompat } from "@oh-my-pi/pi-ai/providers/openai-completions-compat"; import type { Context, Model, Tool } from "@oh-my-pi/pi-ai/types"; +import { detectOpenAICompat, resolveOpenAICompat } from "@oh-my-pi/pi-catalog/compat/openai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import * as z from "zod/v4"; const echoTool: Tool = { diff --git a/packages/ai/test/issue-1227-repro.test.ts b/packages/ai/test/issue-1227-repro.test.ts index ecf8a9181..70a7cd7f3 100644 --- a/packages/ai/test/issue-1227-repro.test.ts +++ b/packages/ai/test/issue-1227-repro.test.ts @@ -16,9 +16,9 @@ * requires when tool history is present. */ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import type { AssistantMessage, Context, Model, Tool } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import * as z from "zod/v4"; function abortedSignal(): AbortSignal { diff --git a/packages/ai/test/issue-1373-repro.test.ts b/packages/ai/test/issue-1373-repro.test.ts index 5173d803f..213498421 100644 --- a/packages/ai/test/issue-1373-repro.test.ts +++ b/packages/ai/test/issue-1373-repro.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { Effort } from "@oh-my-pi/pi-ai/effort"; import { streamBedrock } from "@oh-my-pi/pi-ai/providers/amazon-bedrock"; import type { Context, Model } from "@oh-my-pi/pi-ai/types"; +import { Effort } from "@oh-my-pi/pi-catalog/effort"; const originalSkipAuth = process.env.AWS_BEDROCK_SKIP_AUTH; diff --git a/packages/ai/test/issue-1417-repro.test.ts b/packages/ai/test/issue-1417-repro.test.ts index ed1ffea12..2e4b6af9c 100644 --- a/packages/ai/test/issue-1417-repro.test.ts +++ b/packages/ai/test/issue-1417-repro.test.ts @@ -2,9 +2,9 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { readModelCache } from "@oh-my-pi/pi-ai/model-cache"; -import { resolveProviderModels } from "@oh-my-pi/pi-ai/model-manager"; import type { Model } from "@oh-my-pi/pi-ai/types"; +import { readModelCache } from "@oh-my-pi/pi-catalog/model-cache"; +import { resolveProviderModels } from "@oh-my-pi/pi-catalog/model-manager"; const TTL_MS = 24 * 60 * 60 * 1000; diff --git a/packages/ai/test/issue-1776-repro.test.ts b/packages/ai/test/issue-1776-repro.test.ts index 8a9c9b5e9..0f3e65de1 100644 --- a/packages/ai/test/issue-1776-repro.test.ts +++ b/packages/ai/test/issue-1776-repro.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import type { Context, FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; function createSseResponse(events: unknown[]): Response { const payload = `${events diff --git a/packages/ai/test/issue-1838-repro.test.ts b/packages/ai/test/issue-1838-repro.test.ts index cb23c1dfc..2f2b1a000 100644 --- a/packages/ai/test/issue-1838-repro.test.ts +++ b/packages/ai/test/issue-1838-repro.test.ts @@ -33,9 +33,9 @@ * own native format and would reject the extra key. */ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import type { AssistantMessage, Context, FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; function abortedSignal(): AbortSignal { const controller = new AbortController(); diff --git a/packages/ai/test/issue-2080-repro.test.ts b/packages/ai/test/issue-2080-repro.test.ts index 44d38c584..88dd783cf 100644 --- a/packages/ai/test/issue-2080-repro.test.ts +++ b/packages/ai/test/issue-2080-repro.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import type { Context, FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; function createSseResponse(events: unknown[]): Response { const payload = `${events diff --git a/packages/ai/test/issue-2123-repro.test.ts b/packages/ai/test/issue-2123-repro.test.ts index f94e3d061..fca94ca80 100644 --- a/packages/ai/test/issue-2123-repro.test.ts +++ b/packages/ai/test/issue-2123-repro.test.ts @@ -20,9 +20,9 @@ * the strategy goes with them). */ import { describe, expect, it } from "bun:test"; -import { Effort } from "@oh-my-pi/pi-ai/effort"; import { streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic"; import type { Context, Model, Tool } from "@oh-my-pi/pi-ai/types"; +import { Effort } from "@oh-my-pi/pi-catalog/effort"; const OPUS_46_OAUTH: Model<"anthropic-messages"> = { id: "claude-opus-4-6", diff --git a/packages/ai/test/issue-826-repro.test.ts b/packages/ai/test/issue-826-repro.test.ts index 7cb629cdf..607820b6f 100644 --- a/packages/ai/test/issue-826-repro.test.ts +++ b/packages/ai/test/issue-826-repro.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { Effort } from "@oh-my-pi/pi-ai/effort"; import { streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic"; import type { Context, Model, Tool } from "@oh-my-pi/pi-ai/types"; +import { Effort } from "@oh-my-pi/pi-catalog/effort"; const baseModel: Model<"anthropic-messages"> = { id: "claude-sonnet-4-5", diff --git a/packages/ai/test/issue-827-repro.test.ts b/packages/ai/test/issue-827-repro.test.ts index 56a19c73c..73cedc1fe 100644 --- a/packages/ai/test/issue-827-repro.test.ts +++ b/packages/ai/test/issue-827-repro.test.ts @@ -8,9 +8,9 @@ * reasoning for that single turn rather than dropping `tool_choice` outright. */ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import type { Context, Model, Tool } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import * as z from "zod/v4"; const echoTool: Tool = { diff --git a/packages/ai/test/issue-883-repro.test.ts b/packages/ai/test/issue-883-repro.test.ts index c0710ecb0..197c25b69 100644 --- a/packages/ai/test/issue-883-repro.test.ts +++ b/packages/ai/test/issue-883-repro.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { convertMessages, detectCompat } from "@oh-my-pi/pi-ai/providers/openai-completions"; import type { AssistantMessage, Model } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; function deepseekModel(overrides: Partial>): Model<"openai-completions"> { return { diff --git a/packages/ai/test/issue-911-repro.test.ts b/packages/ai/test/issue-911-repro.test.ts index 8f23c7cd0..56abaa2eb 100644 --- a/packages/ai/test/issue-911-repro.test.ts +++ b/packages/ai/test/issue-911-repro.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import type { Context, FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; function createSseResponse(events: unknown[]): Response { const payload = `${events diff --git a/packages/ai/test/issue-945-repro.test.ts b/packages/ai/test/issue-945-repro.test.ts index 2d24f2065..feb1ca0c8 100644 --- a/packages/ai/test/issue-945-repro.test.ts +++ b/packages/ai/test/issue-945-repro.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import type { Context, Model, Tool } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import * as z from "zod/v4"; const echoTool: Tool = { diff --git a/packages/ai/test/issue-955-repro.test.ts b/packages/ai/test/issue-955-repro.test.ts index b8df44778..b0d43246e 100644 --- a/packages/ai/test/issue-955-repro.test.ts +++ b/packages/ai/test/issue-955-repro.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import type { Context, Model } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; const context: Context = { systemPrompt: ["stable instructions", "cacheable policy"], diff --git a/packages/ai/test/issue-959-repro.test.ts b/packages/ai/test/issue-959-repro.test.ts index f3146ec43..02d622f5e 100644 --- a/packages/ai/test/issue-959-repro.test.ts +++ b/packages/ai/test/issue-959-repro.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import type { Context, FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; function createSseResponse(events: unknown[]): Response { const payload = `${events diff --git a/packages/ai/test/issue-967-vision-guard.test.ts b/packages/ai/test/issue-967-vision-guard.test.ts index 607b7d9bf..928740d4a 100644 --- a/packages/ai/test/issue-967-vision-guard.test.ts +++ b/packages/ai/test/issue-967-vision-guard.test.ts @@ -3,13 +3,13 @@ import { convertAnthropicMessages } from "@oh-my-pi/pi-ai/providers/anthropic"; import { convertMessages as convertGoogleMessages } from "@oh-my-pi/pi-ai/providers/google-shared"; import { convertCodexResponsesMessages } from "@oh-my-pi/pi-ai/providers/openai-codex-responses"; import { convertMessages as convertOpenAICompletionsMessages } from "@oh-my-pi/pi-ai/providers/openai-completions"; -import type { ResolvedOpenAICompat } from "@oh-my-pi/pi-ai/providers/openai-completions-compat"; import { appendResponsesToolResultMessages, convertResponsesInputContent, } from "@oh-my-pi/pi-ai/providers/openai-responses-shared"; import { NON_VISION_IMAGE_PLACEHOLDER } from "@oh-my-pi/pi-ai/providers/vision-guard"; import type { Api, AssistantMessage, Context, Model, ToolResultMessage, Usage } from "@oh-my-pi/pi-ai/types"; +import type { ResolvedOpenAICompat } from "@oh-my-pi/pi-catalog/compat/openai"; const emptyUsage: Usage = { input: 0, diff --git a/packages/ai/test/issue-969-repro.test.ts b/packages/ai/test/issue-969-repro.test.ts index e89145202..5228b4625 100644 --- a/packages/ai/test/issue-969-repro.test.ts +++ b/packages/ai/test/issue-969-repro.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "bun:test"; -import { Effort } from "@oh-my-pi/pi-ai/effort"; -import { getSupportedEfforts } from "@oh-my-pi/pi-ai/model-thinking"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import type { Context, FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; +import { Effort } from "@oh-my-pi/pi-catalog/effort"; +import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking"; const testContext: Context = { messages: [{ role: "user", content: "hello", timestamp: 0 }], diff --git a/packages/ai/test/model-cache.test.ts b/packages/ai/test/model-cache.test.ts index 25353f68a..d63586799 100644 --- a/packages/ai/test/model-cache.test.ts +++ b/packages/ai/test/model-cache.test.ts @@ -3,8 +3,8 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { readModelCache, writeModelCache } from "@oh-my-pi/pi-ai/model-cache"; import type { Model } from "@oh-my-pi/pi-ai/types"; +import { readModelCache, writeModelCache } from "@oh-my-pi/pi-catalog/model-cache"; const TTL_MS = 24 * 60 * 60 * 1000; diff --git a/packages/ai/test/models-cost.test.ts b/packages/ai/test/models-cost.test.ts index b8787fa49..875bc8baf 100644 --- a/packages/ai/test/models-cost.test.ts +++ b/packages/ai/test/models-cost.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { calculateCost, getBundledModel } from "@oh-my-pi/pi-ai/models"; import type { Usage } from "@oh-my-pi/pi-ai/types"; +import { calculateCost, getBundledModel } from "@oh-my-pi/pi-catalog/models"; describe("calculateCost", () => { it("keeps token-based calculation for GitHub Copilot models", () => { diff --git a/packages/ai/test/models-json-no-local-endpoints.test.ts b/packages/ai/test/models-json-no-local-endpoints.test.ts index 0757d26a6..2d4e3e57b 100644 --- a/packages/ai/test/models-json-no-local-endpoints.test.ts +++ b/packages/ai/test/models-json-no-local-endpoints.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import type { Model } from "@oh-my-pi/pi-ai/types"; -import MODELS_JSON from "../src/models.json" with { type: "json" }; +import MODELS_JSON from "@oh-my-pi/pi-catalog/models.json" with { type: "json" }; // Pins the invariant: the committed `models.json` must never carry a // local/self-hosted provider's catalog. Those providers default to an endpoint @@ -16,7 +16,7 @@ import MODELS_JSON from "../src/models.json" with { type: "json" }; // Failure here means: a local provider slipped into models.json — add it to // DISCOVERY_ONLY_PROVIDERS, then `bun run generate-models` and commit the diff. describe("models.json local-endpoint leak guard (regression)", () => { - const catalog = MODELS_JSON as Record>; + const catalog = MODELS_JSON as unknown as Record>; // Providers whose default endpoint is the local machine. They must never // appear as a top-level key in the bundled catalog. diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index 9284296a0..f903bc3eb 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { enrichModelThinking } from "@oh-my-pi/pi-ai/model-thinking"; import { getOpenAICodexTransportDetails, getOpenAICodexWebSocketDebugStats, @@ -7,6 +6,7 @@ import { streamOpenAICodexResponses, } from "@oh-my-pi/pi-ai/providers/openai-codex-responses"; import type { Context, FetchImpl, Model, ProviderSessionState } from "@oh-my-pi/pi-ai/types"; +import { enrichModelThinking } from "@oh-my-pi/pi-catalog/model-thinking"; import { getAgentDir, setAgentDir, TempDir } from "@oh-my-pi/pi-utils"; const originalAgentDir = getAgentDir(); diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts index 7ca6085a7..3427aaa1b 100644 --- a/packages/ai/test/openai-completions-compat.test.ts +++ b/packages/ai/test/openai-completions-compat.test.ts @@ -1,12 +1,10 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { applyOpenRouterRoutingVariant, convertMessages, detectCompat, streamOpenAICompletions, } from "@oh-my-pi/pi-ai/providers/openai-completions"; -import { type ResolvedOpenAICompat, resolveOpenAICompat } from "@oh-my-pi/pi-ai/providers/openai-completions-compat"; import type { AssistantMessage, Context, @@ -15,6 +13,8 @@ import type { OpenAICompat, ToolResultMessage, } from "@oh-my-pi/pi-ai/types"; +import { type ResolvedOpenAICompat, resolveOpenAICompat } from "@oh-my-pi/pi-catalog/compat/openai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; function createAbortedSignal(): AbortSignal { const controller = new AbortController(); diff --git a/packages/ai/test/openai-completions-disable-reasoning.test.ts b/packages/ai/test/openai-completions-disable-reasoning.test.ts index fa933659d..ba6399d5b 100644 --- a/packages/ai/test/openai-completions-disable-reasoning.test.ts +++ b/packages/ai/test/openai-completions-disable-reasoning.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { Effort } from "@oh-my-pi/pi-ai/effort"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import type { Context, FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; +import { Effort } from "@oh-my-pi/pi-catalog/effort"; const testContext: Context = { messages: [{ role: "user", content: "hello", timestamp: 0 }], diff --git a/packages/ai/test/openai-completions-progress-chunk.test.ts b/packages/ai/test/openai-completions-progress-chunk.test.ts index 933f80717..42520d6c5 100644 --- a/packages/ai/test/openai-completions-progress-chunk.test.ts +++ b/packages/ai/test/openai-completions-progress-chunk.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { getOpenAICompletionsStreamIdleTimeoutFallbackMs, isOpenAICompletionsProgressChunk, streamOpenAICompletions, } from "@oh-my-pi/pi-ai/providers/openai-completions"; import type { Context, FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; const openAICompletionsModel = { ...(getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">), diff --git a/packages/ai/test/openai-completions-tool-result-images.test.ts b/packages/ai/test/openai-completions-tool-result-images.test.ts index 368303e88..3ed9c0812 100644 --- a/packages/ai/test/openai-completions-tool-result-images.test.ts +++ b/packages/ai/test/openai-completions-tool-result-images.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { convertMessages } from "@oh-my-pi/pi-ai/providers/openai-completions"; -import type { ResolvedOpenAICompat } from "@oh-my-pi/pi-ai/providers/openai-completions-compat"; import { NON_VISION_IMAGE_PLACEHOLDER } from "@oh-my-pi/pi-ai/providers/vision-guard"; import type { AssistantMessage, Context, Model, ToolResultMessage, Usage } from "@oh-my-pi/pi-ai/types"; +import type { ResolvedOpenAICompat } from "@oh-my-pi/pi-catalog/compat/openai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; const emptyUsage: Usage = { input: 0, diff --git a/packages/ai/test/openai-completions-upstream-provider.test.ts b/packages/ai/test/openai-completions-upstream-provider.test.ts index 067281388..edf61f543 100644 --- a/packages/ai/test/openai-completions-upstream-provider.test.ts +++ b/packages/ai/test/openai-completions-upstream-provider.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import type { Context, FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; const model = { ...(getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">), diff --git a/packages/ai/test/openai-first-event-timeout.test.ts b/packages/ai/test/openai-first-event-timeout.test.ts index ca39097de..fbaebe063 100644 --- a/packages/ai/test/openai-first-event-timeout.test.ts +++ b/packages/ai/test/openai-first-event-timeout.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamAzureOpenAIResponses } from "@oh-my-pi/pi-ai/providers/azure-openai-responses"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; import { streamSimple } from "@oh-my-pi/pi-ai/stream"; import type { Context, FetchImpl, Model, TextContent } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { waitForDelayOrAbort } from "./helpers"; const openAIResponsesModel = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">; diff --git a/packages/ai/test/openai-max-output-tokens-cap.test.ts b/packages/ai/test/openai-max-output-tokens-cap.test.ts index 33dbe2b32..8e862ca0f 100644 --- a/packages/ai/test/openai-max-output-tokens-cap.test.ts +++ b/packages/ai/test/openai-max-output-tokens-cap.test.ts @@ -1,9 +1,9 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import { streamSimple } from "@oh-my-pi/pi-ai/stream"; import type { FetchImpl } from "@oh-my-pi/pi-ai/types"; import { type Context, type Model, OPENAI_MAX_OUTPUT_TOKENS } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; // Output-token wire policy for OpenAI-family providers: // - Non-aggregator completions + all responses: clamp to OPENAI_MAX_OUTPUT_TOKENS diff --git a/packages/ai/test/openai-responses-cache-affinity.test.ts b/packages/ai/test/openai-responses-cache-affinity.test.ts index 68c8fab50..b59757540 100644 --- a/packages/ai/test/openai-responses-cache-affinity.test.ts +++ b/packages/ai/test/openai-responses-cache-affinity.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { type OpenAIResponsesOptions, streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; import type { Context, FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; const model = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">; diff --git a/packages/ai/test/openai-responses-history-payload.test.ts b/packages/ai/test/openai-responses-history-payload.test.ts index 6b5020d1d..a894542b5 100644 --- a/packages/ai/test/openai-responses-history-payload.test.ts +++ b/packages/ai/test/openai-responses-history-payload.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamOpenAICodexResponses } from "@oh-my-pi/pi-ai/providers/openai-codex-responses"; import { type OpenAIResponsesOptions, streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; import type { Context, Model, ProviderSessionState } from "@oh-my-pi/pi-ai/types"; import { createOpenAIResponsesHistoryPayload, truncateResponseItemId } from "@oh-my-pi/pi-ai/utils"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; function createAbortedSignal(): AbortSignal { const controller = new AbortController(); diff --git a/packages/ai/test/openai-responses-omit-max-output-tokens.test.ts b/packages/ai/test/openai-responses-omit-max-output-tokens.test.ts index 36432158f..0831b0fcd 100644 --- a/packages/ai/test/openai-responses-omit-max-output-tokens.test.ts +++ b/packages/ai/test/openai-responses-omit-max-output-tokens.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamSimple } from "@oh-my-pi/pi-ai/stream"; import type { Context, FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; const baseModel = getBundledModel("openai", "gpt-4o-mini") as Model<"openai-responses">; diff --git a/packages/ai/test/openai-responses-system-prompt.test.ts b/packages/ai/test/openai-responses-system-prompt.test.ts index 329060eb6..2f447d9a8 100644 --- a/packages/ai/test/openai-responses-system-prompt.test.ts +++ b/packages/ai/test/openai-responses-system-prompt.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; import type { Context, FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; // Non-reasoning model on api.openai.com (canonical path) const gpt4oMiniModel = getBundledModel("openai", "gpt-4o-mini") as Model<"openai-responses">; diff --git a/packages/ai/test/openai-tool-strict-mode.test.ts b/packages/ai/test/openai-tool-strict-mode.test.ts index ee48ab8c0..6f0491f1c 100644 --- a/packages/ai/test/openai-tool-strict-mode.test.ts +++ b/packages/ai/test/openai-tool-strict-mode.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; import type { Context, FetchImpl, Model, OpenAICompat, ProviderSessionState, Tool } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import * as z from "zod/v4"; const testTool: Tool = { diff --git a/packages/ai/test/provider-fetch-override.test.ts b/packages/ai/test/provider-fetch-override.test.ts index 4443cab04..4184c9024 100644 --- a/packages/ai/test/provider-fetch-override.test.ts +++ b/packages/ai/test/provider-fetch-override.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; import type { Context, FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; const openAIResponsesModel = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">; const openAICompletionsModel = { diff --git a/packages/ai/test/provider-registry.test.ts b/packages/ai/test/provider-registry.test.ts index cd4391778..ee9bd8c7d 100644 --- a/packages/ai/test/provider-registry.test.ts +++ b/packages/ai/test/provider-registry.test.ts @@ -1,7 +1,6 @@ import { Database } from "bun:sqlite"; import { afterEach, describe, expect, test, vi } from "bun:test"; import { AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; -import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-ai/provider-models/descriptors"; import { PASTE_CODE_LOGIN_PROVIDERS } from "@oh-my-pi/pi-ai/registry"; import { getOAuthProviders, @@ -14,7 +13,7 @@ import type { OAuthCredentials, OAuthProvider } from "@oh-my-pi/pi-ai/registry/o import { getEnvApiKey } from "@oh-my-pi/pi-ai/stream"; const FIXTURE_SOURCE = "provider-registry-test"; -const ENV_KEYS = ["ZENMUX_API_KEY", "EXA_API_KEY"] as const; +const ENV_KEYS = ["ZENMUX_API_KEY", "EXA_API_KEY", "XAI_OAUTH_TOKEN"] as const; const originalEnv = new Map(ENV_KEYS.map(key => [key, Bun.env[key]])); afterEach(() => { @@ -30,30 +29,21 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe("provider registry derivation", () => { - test("descriptors are derived for standard model providers, excluding special-managed ones", () => { - const zenmux = PROVIDER_DESCRIPTORS.find(descriptor => descriptor.providerId === "zenmux"); - expect(zenmux).toBeDefined(); - expect(zenmux?.defaultModel).toBe("anthropic/claude-opus-4.6"); - // The derived factory carries the provider identity through. - expect(zenmux?.createModelManagerOptions({ apiKey: "k" }).providerId).toBe("zenmux"); - - // openai-codex is special-managed (bespoke runtime factory) → excluded from descriptors, - // but still a known model provider with a default. - expect(PROVIDER_DESCRIPTORS.some(descriptor => descriptor.providerId === "openai-codex")).toBe(false); - expect(DEFAULT_MODEL_PER_PROVIDER["openai-codex"]).toBe("gpt-5.4"); - // Login-only tools have no default model. - expect(DEFAULT_MODEL_PER_PROVIDER).not.toHaveProperty("kagi"); - }); - - test("env-key map merges registry defs with legacy non-provider keys", () => { +describe("provider registry auth surface", () => { + test("env-key map merges catalog names, registry defs, and legacy keys", () => { Bun.env.ZENMUX_API_KEY = "zenmux-env"; Bun.env.EXA_API_KEY = "exa-env"; + // Plain name derived from the catalog table's `envVars`. expect(getEnvApiKey("zenmux")).toBe("zenmux-env"); // Legacy search-tool key preserved (not a registry provider def). expect(getEnvApiKey("exa")).toBe("exa-env"); }); + test("multi-var catalog env fallback picks names in order", () => { + Bun.env.XAI_OAUTH_TOKEN = "xai-oauth-env"; + expect(getEnvApiKey("xai-oauth")).toBe("xai-oauth-env"); + }); + test("login list contains loginable providers and excludes env-only model providers", () => { const ids = getOAuthProviders().map(provider => provider.id); expect(ids).toContain("zenmux"); diff --git a/packages/ai/test/provider-response.test.ts b/packages/ai/test/provider-response.test.ts index 896bdbc32..be90b0dcb 100644 --- a/packages/ai/test/provider-response.test.ts +++ b/packages/ai/test/provider-response.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamSimple } from "@oh-my-pi/pi-ai/stream"; import type { Context, FetchImpl, Model, ProviderResponseMetadata } from "@oh-my-pi/pi-ai/types"; import { normalizeProviderResponse, notifyProviderResponse } from "@oh-my-pi/pi-ai/utils/provider-response"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; describe("provider response metadata", () => { it("normalizes response status, headers, and request id", () => { diff --git a/packages/ai/test/raw-sse-sdk-capture.test.ts b/packages/ai/test/raw-sse-sdk-capture.test.ts index 54b0f2bc8..35f389a7a 100644 --- a/packages/ai/test/raw-sse-sdk-capture.test.ts +++ b/packages/ai/test/raw-sse-sdk-capture.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic"; import type { AnthropicMessagesClientLike } from "@oh-my-pi/pi-ai/providers/anthropic-client"; import type { RawMessageStreamEvent } from "@oh-my-pi/pi-ai/providers/anthropic-wire"; @@ -7,6 +6,7 @@ import { streamAzureOpenAIResponses } from "@oh-my-pi/pi-ai/providers/azure-open import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; import type { Context, FetchImpl, Model, RawSseEvent } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; const context: Context = { messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], diff --git a/packages/ai/test/stream-markup-healing.test.ts b/packages/ai/test/stream-markup-healing.test.ts index f11930a92..1265293d8 100644 --- a/packages/ai/test/stream-markup-healing.test.ts +++ b/packages/ai/test/stream-markup-healing.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import { stream } from "@oh-my-pi/pi-ai/stream"; import type { Context, FetchImpl, Model, Tool, ToolCall } from "@oh-my-pi/pi-ai/types"; import { getStreamMarkupHealingPattern, StreamMarkupHealing } from "@oh-my-pi/pi-ai/utils/stream-markup-healing"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; interface SseToolCallDelta { index: number; diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 16b08a72d..6c972dbdf 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -4,10 +4,10 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { Effort } from "@oh-my-pi/pi-ai"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { __resetVertexTokenCache } from "@oh-my-pi/pi-ai/providers/google-auth"; import { complete, getEnvApiKey, stream } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, ImageContent, Model, OptionsForApi, Tool, ToolResultMessage } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { $which } from "@oh-my-pi/pi-utils"; import * as z from "zod/v4"; import { e2eApiKey, resolveApiKey } from "./oauth"; diff --git a/packages/ai/test/tokens.test.ts b/packages/ai/test/tokens.test.ts index dcb62d543..0637b6847 100644 --- a/packages/ai/test/tokens.test.ts +++ b/packages/ai/test/tokens.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { stream } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, Model, OptionsForApi } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { e2eApiKey, resolveApiKey } from "./oauth"; // Resolve OAuth tokens at module level (async, runs before tests) diff --git a/packages/ai/test/tool-call-without-result.test.ts b/packages/ai/test/tool-call-without-result.test.ts index 417730f55..758d9969d 100644 --- a/packages/ai/test/tool-call-without-result.test.ts +++ b/packages/ai/test/tool-call-without-result.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { complete } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, Model, OptionsForApi, Tool } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import * as z from "zod/v4"; import { e2eApiKey, resolveApiKey } from "./oauth"; diff --git a/packages/ai/test/total-tokens.test.ts b/packages/ai/test/total-tokens.test.ts index 200e4d0b9..9cc991b31 100644 --- a/packages/ai/test/total-tokens.test.ts +++ b/packages/ai/test/total-tokens.test.ts @@ -13,9 +13,9 @@ */ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { complete } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, Model, OptionsForApi, Usage } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { e2eApiKey, resolveApiKey } from "./oauth"; // Resolve OAuth tokens at module level (async, runs before tests) diff --git a/packages/ai/test/unicode-surrogate.test.ts b/packages/ai/test/unicode-surrogate.test.ts index 2dafcd876..0106484f6 100644 --- a/packages/ai/test/unicode-surrogate.test.ts +++ b/packages/ai/test/unicode-surrogate.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { complete } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, Model, OptionsForApi, ToolResultMessage } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import * as z from "zod/v4"; import { e2eApiKey, resolveApiKey } from "./oauth"; diff --git a/packages/ai/test/wafer.live.ts b/packages/ai/test/wafer.live.ts index 059e77f16..949108903 100644 --- a/packages/ai/test/wafer.live.ts +++ b/packages/ai/test/wafer.live.ts @@ -7,9 +7,10 @@ * `model` field preserved verbatim (`GLM-5.1`, not lowercased) and a non-empty * assistant text returned. */ -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; + import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import type { Context, FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; const apiKey = process.env.WAFER_PASS_API_KEY ?? process.env.WAFER_SERVERLESS_API_KEY; if (!apiKey) { diff --git a/packages/ai/test/xai-oauth-effort-strip.test.ts b/packages/ai/test/xai-oauth-effort-strip.test.ts index 2239abadb..f63125883 100644 --- a/packages/ai/test/xai-oauth-effort-strip.test.ts +++ b/packages/ai/test/xai-oauth-effort-strip.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { modelOmitsReasoningEffort } from "@oh-my-pi/pi-ai/model-thinking"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { modelOmitsReasoningEffort } from "@oh-my-pi/pi-catalog/model-thinking"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; // Pins fix #2 of the compaction effort-override bug. Before this fix, // `resolveOpenAiReasoningEffort` called `requireSupportedEffort` which threw diff --git a/packages/ai/test/xhigh.test.ts b/packages/ai/test/xhigh.test.ts index febd6fd59..e3ba0d4cf 100644 --- a/packages/ai/test/xhigh.test.ts +++ b/packages/ai/test/xhigh.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { stream } from "@oh-my-pi/pi-ai/stream"; import type { Context, Model } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { e2eApiKey } from "./oauth"; function makeContext(): Context { diff --git a/packages/ai/test/xiaomi-tp-login-integration.test.ts b/packages/ai/test/xiaomi-tp-login-integration.test.ts index dac9fc45b..3e99c40d8 100644 --- a/packages/ai/test/xiaomi-tp-login-integration.test.ts +++ b/packages/ai/test/xiaomi-tp-login-integration.test.ts @@ -14,9 +14,9 @@ */ import { describe, expect, it } from "bun:test"; -import { xiaomiModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; import { loginXiaomi } from "@oh-my-pi/pi-ai/registry/oauth/xiaomi"; import type { FetchImpl } from "@oh-my-pi/pi-ai/types"; +import { xiaomiModelManagerOptions } from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; // Realistic tp- key (same format as user's key, but a dummy value for testing) const TP_KEY = "tp-ci1p8t1w4e1sbxgyc8v65tnrjbzro287igmvyf25van9mt76"; diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md new file mode 100644 index 000000000..642ffbda7 --- /dev/null +++ b/packages/catalog/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +## [Unreleased] + +### Added + +- New `@oh-my-pi/pi-catalog` package: the model catalog extracted from `@oh-my-pi/pi-ai`. Owns the bundled `models.json` and its generation pipeline (`scripts/generate-models.ts`), the core model data types (`Model`, `Api`, `ThinkingConfig`, `Effort`, `Usage`, compat interfaces), thinking metadata enrichment and generated policies (`model-thinking.ts`), the SQLite model cache and model manager, per-provider discovery factories (`provider-models/`), the discovery protocol clients (`discovery/`), and the new `CATALOG_PROVIDERS` table — the single source of truth for provider ids, default models, and discovery wiring (`KnownProvider`, `PROVIDER_DESCRIPTORS`, and `DEFAULT_MODEL_PER_PROVIDER` are derived from it). +- New `identity/` module centralizing model-identity concerns that were previously duplicated across packages: family classification and version parsing (`identity/classify.ts`, extracted from pi-ai's `model-thinking` internals), canonical model equivalence with injected reference data (`identity/equivalence.ts`, from coding-agent's `model-equivalence`), proxy/reseller reference lookup (`identity/reference.ts`, from coding-agent's `model-registry`), bracket-affix and id-segment helpers (`identity/id.ts`), a single trailing-marker vocabulary with canonical vs reference flavors (`identity/markers.ts` — `search` stays reference-only so Perplexity's `sonar-pro-search` remains canonical-distinct), and provider priority ordering (`identity/priority.ts`). +- Memoized bundled-reference accessors (`getBundledCanonicalReferenceData` / `getBundledModelReferenceIndex` in `identity/bundled.ts`): one lazy walk of the bundled catalog feeds both canonical equivalence and proxy-reference lookup, so consumers no longer hand-roll the glue. +- `identity/selection.ts`: pure canonical-variant selection (`resolveCanonicalVariant`, `buildCanonicalModelOrder`, `CanonicalVariantPreferences`) extracted from the coding-agent registry — provider rank, then exact-id match, variant source, id length, and candidate order. + +### Changed + +- Provider catalog entries now carry the runtime API-key env fallback as an ordered `envVars` list; `catalogDiscovery.envVars` became an optional generation-time override (only `cursor` and `vercel-ai-gateway` differ) and `PROVIDER_DESCRIPTORS` materializes the resolved list for `generate-models.ts`. +- `Model`'s api parameter now defaults to `Api` instead of `any` (`Model`), so bare `Model` no longer behaves as `Model` at call sites. + +### Fixed + +- Wired `@oh-my-pi/pi-catalog` into the release publish package list, tarball install smoke test, and root `bun generate-models` script. diff --git a/packages/catalog/package.json b/packages/catalog/package.json new file mode 100644 index 000000000..0f163e91e --- /dev/null +++ b/packages/catalog/package.json @@ -0,0 +1,99 @@ +{ + "type": "module", + "name": "@oh-my-pi/pi-catalog", + "version": "15.10.10", + "description": "Model catalog for omp: bundled model database, provider discovery descriptors, model identity, classification, and equivalence", + "homepage": "https://omp.sh", + "author": "Can Boluk", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/can1357/oh-my-pi.git", + "directory": "packages/catalog" + }, + "bugs": { + "url": "https://github.com/can1357/oh-my-pi/issues" + }, + "keywords": [ + "ai", + "llm", + "models", + "catalog", + "discovery" + ], + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "check": "biome check . && bun run check:types", + "check:types": "tsgo -p tsconfig.json --noEmit", + "lint": "biome lint .", + "test": "bun test --parallel", + "fix": "biome check --write --unsafe .", + "fmt": "biome format --write .", + "generate-models": "bun scripts/generate-models.ts" + }, + "dependencies": { + "@bufbuild/protobuf": "catalog:", + "@oh-my-pi/pi-utils": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@oh-my-pi/pi-ai": "catalog:", + "@types/bun": "catalog:" + }, + "engines": { + "bun": ">=1.3.14" + }, + "files": [ + "src", + "README.md", + "CHANGELOG.md" + ], + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./models.json": { + "types": "./src/models.json.d.ts", + "import": "./src/models.json" + }, + "./provider-models": { + "types": "./src/provider-models/index.ts", + "import": "./src/provider-models/index.ts" + }, + "./provider-models/*": { + "types": "./src/provider-models/*.ts", + "import": "./src/provider-models/*.ts" + }, + "./discovery": { + "types": "./src/discovery/index.ts", + "import": "./src/discovery/index.ts" + }, + "./discovery/*": { + "types": "./src/discovery/*.ts", + "import": "./src/discovery/*.ts" + }, + "./identity": { + "types": "./src/identity/index.ts", + "import": "./src/identity/index.ts" + }, + "./identity/*": { + "types": "./src/identity/*.ts", + "import": "./src/identity/*.ts" + }, + "./wire/*": { + "types": "./src/wire/*.ts", + "import": "./src/wire/*.ts" + }, + "./compat/*": { + "types": "./src/compat/*.ts", + "import": "./src/compat/*.ts" + }, + "./*": { + "types": "./src/*.ts", + "import": "./src/*.ts" + }, + "./*.js": "./src/*.ts" + } +} diff --git a/packages/ai/scripts/generate-models.ts b/packages/catalog/scripts/generate-models.ts similarity index 95% rename from packages/ai/scripts/generate-models.ts rename to packages/catalog/scripts/generate-models.ts index 9fb9a417d..a1de0a3c4 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/catalog/scripts/generate-models.ts @@ -10,8 +10,12 @@ const COPILOT_PREMIUM_MULTIPLIERS: Record = { }; import * as path from "node:path"; +import { AuthStorage, type OAuthAccess, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; +import type { OAuthProvider } from "@oh-my-pi/pi-ai/oauth/types"; +import { getGitLabDuoModels } from "@oh-my-pi/pi-ai/providers/gitlab-duo"; import { $env } from "@oh-my-pi/pi-utils"; -import { AuthStorage, type OAuthAccess, SqliteAuthCredentialStore } from "../src/auth-storage"; +import { fetchAntigravityDiscoveryModels } from "../src/discovery/antigravity"; +import { fetchCodexModels } from "../src/discovery/codex"; import { createModelManager } from "../src/model-manager"; import { applyGeneratedModelPolicies, @@ -24,8 +28,8 @@ import { type CatalogDiscoveryConfig, type CatalogProviderDescriptor, isCatalogDescriptor, - PROVIDER_DESCRIPTORS, -} from "../src/provider-models/descriptors"; +} from "../src/provider-models/descriptor-types"; +import { PROVIDER_DESCRIPTORS } from "../src/provider-models/descriptors"; import { ANTHROPIC_CURATED_FALLBACK_MODELS, buildXaiOAuthStaticSeed, @@ -37,12 +41,8 @@ import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS, } from "../src/provider-models/openai-compat"; -import { getGitLabDuoModels } from "../src/providers/gitlab-duo"; -import { JWT_CLAIM_PATH } from "../src/providers/openai-codex/constants"; -import type { OAuthProvider } from "../src/registry/oauth/types"; import type { Model } from "../src/types"; -import { fetchAntigravityDiscoveryModels } from "../src/utils/discovery/antigravity"; -import { fetchCodexModels } from "../src/utils/discovery/codex"; +import { JWT_CLAIM_PATH } from "../src/wire/codex"; const packageRoot = path.join(import.meta.dir, ".."); @@ -57,7 +57,7 @@ const packageRoot = path.join(import.meta.dir, ".."); const DISCOVERY_ONLY_PROVIDERS = new Set(["ollama", "vllm", "lm-studio", "litellm"]); async function resolveProviderApiKey(providerId: string, catalog: CatalogDiscoveryConfig): Promise { - for (const envVar of catalog.envVars) { + for (const envVar of catalog.envVars ?? []) { const value = $env[envVar as keyof typeof $env]; if (typeof value === "string" && value.length > 0) { return value; @@ -253,7 +253,8 @@ function applyFireworksKimiMaxTokensCap(models: readonly Model[]): Model[] { function applyFireworksDeepSeekReasoningShape(models: readonly Model[]): Model[] { return models.map(model => { if (model.provider !== "fireworks" || model.api !== "openai-completions") return model; - return stripFireworksDeepSeekThinkingToggle(model, model.id); + // `.api` equality doesn't narrow the generic; the guard makes this cast sound. + return stripFireworksDeepSeekThinkingToggle(model as Model<"openai-completions">, model.id); }); } diff --git a/packages/ai/src/providers/openai-completions-compat.ts b/packages/catalog/src/compat/openai.ts similarity index 100% rename from packages/ai/src/providers/openai-completions-compat.ts rename to packages/catalog/src/compat/openai.ts diff --git a/packages/ai/src/utils/discovery/antigravity.ts b/packages/catalog/src/discovery/antigravity.ts similarity index 97% rename from packages/ai/src/utils/discovery/antigravity.ts rename to packages/catalog/src/discovery/antigravity.ts index 454920126..8fea6663a 100644 --- a/packages/ai/src/utils/discovery/antigravity.ts +++ b/packages/catalog/src/discovery/antigravity.ts @@ -1,7 +1,7 @@ import * as z from "zod/v4"; -import { getAntigravityUserAgent } from "../../providers/google-gemini-headers"; -import type { Model } from "../../types"; -import { toPositiveNumber } from "../../utils"; +import type { Model } from "../types"; +import { toPositiveNumber } from "../utils"; +import { getAntigravityUserAgent } from "../wire/gemini-headers"; const DEFAULT_ANTIGRAVITY_DISCOVERY_ENDPOINTS = [ "https://daily-cloudcode-pa.googleapis.com", diff --git a/packages/ai/src/utils/discovery/codex.ts b/packages/catalog/src/discovery/codex.ts similarity index 98% rename from packages/ai/src/utils/discovery/codex.ts rename to packages/catalog/src/discovery/codex.ts index 1b68dbeb1..9a61c4d5e 100644 --- a/packages/ai/src/utils/discovery/codex.ts +++ b/packages/catalog/src/discovery/codex.ts @@ -1,7 +1,7 @@ import * as z from "zod/v4"; -import { CODEX_BASE_URL, OPENAI_HEADER_VALUES, OPENAI_HEADERS } from "../../providers/openai-codex/constants"; -import type { Model } from "../../types"; -import { isRecord } from "../../utils"; +import type { Model } from "../types"; +import { isRecord } from "../utils"; +import { CODEX_BASE_URL, OPENAI_HEADER_VALUES, OPENAI_HEADERS } from "../wire/codex"; const DEFAULT_MODEL_LIST_PATHS = ["/codex/models", "/models"] as const; const DEFAULT_CONTEXT_WINDOW = 272_000; diff --git a/packages/ai/src/providers/cursor/gen/agent_pb.ts b/packages/catalog/src/discovery/cursor-gen/agent_pb.ts similarity index 100% rename from packages/ai/src/providers/cursor/gen/agent_pb.ts rename to packages/catalog/src/discovery/cursor-gen/agent_pb.ts diff --git a/packages/ai/src/utils/discovery/cursor.ts b/packages/catalog/src/discovery/cursor.ts similarity index 98% rename from packages/ai/src/utils/discovery/cursor.ts rename to packages/catalog/src/discovery/cursor.ts index db98bbb71..51664de50 100644 --- a/packages/ai/src/utils/discovery/cursor.ts +++ b/packages/catalog/src/discovery/cursor.ts @@ -1,9 +1,9 @@ import * as http2 from "node:http2"; import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; import * as z from "zod/v4"; -import { getBundledModels } from "../../models"; -import { GetUsableModelsRequestSchema, GetUsableModelsResponseSchema } from "../../providers/cursor/gen/agent_pb"; -import type { Model } from "../../types"; +import { getBundledModels } from "../models"; +import type { Model } from "../types"; +import { GetUsableModelsRequestSchema, GetUsableModelsResponseSchema } from "./cursor-gen/agent_pb"; const CURSOR_DEFAULT_BASE_URL = "https://api2.cursor.sh"; const CURSOR_DEFAULT_CLIENT_VERSION = "cli-2026.02.13-41ac335"; diff --git a/packages/ai/src/utils/discovery/gemini.ts b/packages/catalog/src/discovery/gemini.ts similarity index 97% rename from packages/ai/src/utils/discovery/gemini.ts rename to packages/catalog/src/discovery/gemini.ts index c1c0c27f0..1bc5c4f0e 100644 --- a/packages/ai/src/utils/discovery/gemini.ts +++ b/packages/catalog/src/discovery/gemini.ts @@ -1,7 +1,7 @@ import * as z from "zod/v4"; -import { getBundledModels } from "../../models"; -import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "../../provider-models/discovery-constants"; -import type { FetchImpl, Model } from "../../types"; +import { getBundledModels } from "../models"; +import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "../provider-models/discovery-constants"; +import type { FetchImpl, Model } from "../types"; const GOOGLE_GENERATIVE_AI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; const DEFAULT_PAGE_SIZE = 100; diff --git a/packages/ai/src/utils/discovery/index.ts b/packages/catalog/src/discovery/index.ts similarity index 100% rename from packages/ai/src/utils/discovery/index.ts rename to packages/catalog/src/discovery/index.ts diff --git a/packages/ai/src/utils/discovery/openai-compatible.ts b/packages/catalog/src/discovery/openai-compatible.ts similarity index 97% rename from packages/ai/src/utils/discovery/openai-compatible.ts rename to packages/catalog/src/discovery/openai-compatible.ts index 24e74afd8..2f2341520 100644 --- a/packages/ai/src/utils/discovery/openai-compatible.ts +++ b/packages/catalog/src/discovery/openai-compatible.ts @@ -1,6 +1,6 @@ import * as z from "zod/v4"; -import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "../../provider-models/discovery-constants"; -import type { Api, FetchImpl, Model, Provider } from "../../types"; +import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "../provider-models/discovery-constants"; +import type { Api, FetchImpl, Model, Provider } from "../types"; const MODELS_PATH = "/models"; diff --git a/packages/ai/src/effort.ts b/packages/catalog/src/effort.ts similarity index 100% rename from packages/ai/src/effort.ts rename to packages/catalog/src/effort.ts diff --git a/packages/ai/src/utils/fireworks-model-id.ts b/packages/catalog/src/fireworks-model-id.ts similarity index 100% rename from packages/ai/src/utils/fireworks-model-id.ts rename to packages/catalog/src/fireworks-model-id.ts diff --git a/packages/catalog/src/identity/bundled.ts b/packages/catalog/src/identity/bundled.ts new file mode 100644 index 000000000..6b111d34e --- /dev/null +++ b/packages/catalog/src/identity/bundled.ts @@ -0,0 +1,38 @@ +/** + * Memoized reference datasets over the bundled model catalog. + * + * Lazy: walking every bundled model (~12K) triggers thinking enrichment, so + * the walk is deferred off module load and performed once for both datasets + * (canonical equivalence + proxy reference lookup). Consumers that need + * non-bundled reference data use the pure builders directly + * ({@link buildCanonicalReferenceData} / {@link buildModelReferenceIndex}). + */ +import { getBundledModels, getBundledProviders } from "../models"; +import type { Api, Model } from "../types"; +import { buildCanonicalReferenceData, type CanonicalReferenceData } from "./equivalence"; +import { buildModelReferenceIndex, type ModelReferenceIndex } from "./reference"; + +let bundledModels: readonly Model[] | undefined; + +function getBundledModelList(): readonly Model[] { + bundledModels ??= getBundledProviders().flatMap( + provider => getBundledModels(provider as Parameters[0]) as Model[], + ); + return bundledModels; +} + +let canonicalReference: CanonicalReferenceData | undefined; + +/** Canonical-equivalence reference data over the bundled catalog. */ +export function getBundledCanonicalReferenceData(): CanonicalReferenceData { + canonicalReference ??= buildCanonicalReferenceData(getBundledModelList()); + return canonicalReference; +} + +let referenceIndex: ModelReferenceIndex | undefined; + +/** Proxy-reference index over the bundled catalog. */ +export function getBundledModelReferenceIndex(): ModelReferenceIndex { + referenceIndex ??= buildModelReferenceIndex(getBundledModelList()); + return referenceIndex; +} diff --git a/packages/catalog/src/identity/classify.ts b/packages/catalog/src/identity/classify.ts new file mode 100644 index 000000000..994b1bb2a --- /dev/null +++ b/packages/catalog/src/identity/classify.ts @@ -0,0 +1,141 @@ +/** + * Model-id classification: parse a model id into its family (gemini / anthropic / + * openai), kind/variant, and version. This is the shared layer both catalog + * policy rules (`model-thinking.ts`) and downstream consumers build on — + * classification lives here, the rules that consume it stay with their domain. + */ + +export type SemVer = { + major: number; + minor: number; + patch: number; +}; + +export type GeminiKind = "pro" | "flash"; +export type AnthropicKind = "opus" | "sonnet" | "fable" | "mythos"; +export type OpenAIVariant = "base" | "codex" | "codex-max" | "codex-mini" | "codex-spark" | "mini" | "max" | "nano"; + +export interface GeminiModel { + family: "gemini"; + kind: GeminiKind; + version: SemVer; +} + +export interface AnthropicModel { + family: "anthropic"; + kind: AnthropicKind; + version: SemVer; +} + +export interface OpenAIModel { + family: "openai"; + variant: OpenAIVariant; + version: SemVer; +} + +export interface UnknownModel { + family: "unknown"; + id: string; +} + +export type ParsedModel = GeminiModel | AnthropicModel | OpenAIModel | UnknownModel; + +/** Strip a provider namespace prefix (`openai/gpt-5.4` → `gpt-5.4`). */ +export function bareModelId(modelId: string): string { + const p = modelId.lastIndexOf("/"); + return p !== -1 ? modelId.slice(p + 1) : modelId; +} + +export function parseKnownModel(modelId: string): ParsedModel { + const canonicalId = bareModelId(modelId); + return ( + parseGeminiModel(canonicalId) ?? + parseAnthropicModel(canonicalId) ?? + parseOpenAIModel(canonicalId) ?? { family: "unknown", id: canonicalId } + ); +} + +const GEMINI_SUFFIX = "-preview"; +export function parseGeminiModel(modelId: string): GeminiModel | null { + if (modelId.endsWith(GEMINI_SUFFIX)) { + modelId = modelId.slice(0, -GEMINI_SUFFIX.length); + } + const match = /gemini-(\d+(?:\.\d+){0,2})-(pro|flash)\b/.exec(modelId); + if (!match) { + return null; + } + const version = parseSemVer(match[1]); + if (!version) { + return null; + } + return { family: "gemini", kind: match[2] as GeminiKind, version }; +} + +export function parseAnthropicModel(modelId: string): AnthropicModel | null { + const match = /claude-(opus|sonnet|fable|mythos)-(\d{1,2}(?:[.-]\d{1,2}){0,2})\b/.exec(modelId); + if (!match) { + return null; + } + const version = parseSemVer(match[2]); + if (!version) { + return null; + } + return { family: "anthropic", kind: match[1] as AnthropicKind, version }; +} + +export function parseOpenAIModel(modelId: string): OpenAIModel | null { + const match = /gpt-(\d+(?:\.\d+){0,2})(?:-(codex-spark|codex-mini|codex-max|codex|mini|max|nano))?\b/.exec(modelId); + if (!match) { + return null; + } + const version = parseSemVer(match[1]); + if (!version) { + return null; + } + return { family: "openai", variant: (match[2] as OpenAIVariant | undefined) ?? "base", version }; +} + +export function isFableOrMythos(kind: AnthropicKind): boolean { + return kind === "fable" || kind === "mythos"; +} + +function createSemVer(major: number, minor: number, patch = 0): SemVer { + return { major, minor, patch }; +} + +// extend this table if we need anything more than 9.10 +const precomputeTable: Record = {}; +for (let major = 0; major <= 9; major++) { + for (let minor = 0; minor <= 10; minor++) { + const version = createSemVer(major, minor, 0); + precomputeTable[`${major}.${minor}`] = version; + precomputeTable[`${major}-${minor}`] = version; + } + precomputeTable[`${major}`] = createSemVer(major, 0, 0); +} + +export function parseSemVer(version: string): SemVer | null { + return precomputeTable[version] ?? null; +} + +export function semverGte(left: SemVer | string, right: SemVer | string): boolean { + return compareSemVer(left, right) >= 0; +} + +export function semverEqual(left: SemVer | string, right: SemVer | string): boolean { + return compareSemVer(left, right) === 0; +} + +export function compareSemVer(left: SemVer | string | null, right: SemVer | string | null): number { + left = typeof left === "string" ? parseSemVer(left) : left; + right = typeof right === "string" ? parseSemVer(right) : right; + if (!left || !right) return (left ? 1 : 0) - (right ? 1 : 0); + + if (left.major !== right.major) { + return left.major - right.major; + } + if (left.minor !== right.minor) { + return left.minor - right.minor; + } + return left.patch - right.patch; +} diff --git a/packages/coding-agent/src/config/model-equivalence.ts b/packages/catalog/src/identity/equivalence.ts similarity index 93% rename from packages/coding-agent/src/config/model-equivalence.ts rename to packages/catalog/src/identity/equivalence.ts index 75fedfc2b..c47a65498 100644 --- a/packages/coding-agent/src/config/model-equivalence.ts +++ b/packages/catalog/src/identity/equivalence.ts @@ -1,9 +1,6 @@ -import { type Api, getBundledModels, getBundledProviders, type Model } from "@oh-my-pi/pi-ai"; -import { - getBracketStrippedModelIdCandidates, - getLongestModelLikeIdSegment, - getModelLikeIdSegments, -} from "./model-id-affixes"; +import type { Api, Model } from "../types"; +import { getBracketStrippedModelIdCandidates, getLongestModelLikeIdSegment, getModelLikeIdSegments } from "./id"; +import { CANONICAL_TRAILING_MARKER_PATTERN } from "./markers"; export type CanonicalModelSource = "override" | "bundled" | "heuristic" | "fallback"; @@ -31,10 +28,11 @@ export interface CanonicalModelIndex { bySelector: Map; } -interface CanonicalReferenceData { - references: Map>; - officialIds: Set; - suffixAliases: Map; +export interface CanonicalReferenceData { + references: ReadonlyMap>; + officialIds: ReadonlySet; + suffixAliases: ReadonlyMap; + [kResolutionCaches]?: WeakMap>; } interface CompiledEquivalenceConfig { @@ -47,19 +45,14 @@ interface ResolvedCanonicalModel { source: CanonicalModelSource; } -const TRAILING_MARKER_PATTERN = - /[-:](?:thinking|customtools|high|low|medium|minimal|xhigh|free|cloud|exacto|nitro|original|optimized|nvfp4|fp8|fp4|bf16|int8|int4)$/i; +const TRAILING_MARKER_PATTERN = CANONICAL_TRAILING_MARKER_PATTERN; const WRAPPER_PREFIXES = ["duo-chat-"] as const; -let referenceDataCache: CanonicalReferenceData | undefined; const EMPTY_COMPILED_EQUIVALENCE: CompiledEquivalenceConfig = { overrides: new Map(), exclude: new Set(), }; -const kModelResolutionCache = Symbol("model-equivalence.resolutionCache"); -interface CompiledEquivalenceConfigWithCache extends CompiledEquivalenceConfig { - [kModelResolutionCache]?: Map; -} +const kResolutionCaches = Symbol("model-equivalence.resolutionCaches"); const FAMILY_EXTRACTION_PATTERNS = [ /(?:^|[/:._-])((?:claude|gemini|gpt|grok|glm|qwen|minimax|kimi|deepseek|llama|gemma|nova|mistral|ministral|pixtral|codestral|devstral|magistral|ernie|doubao|seed|aion|olmo|molmo|nemotron|palmyra|command|codex|coder|o[1345])[-a-z0-9.]+)(?::|$)/i, /(?:^|[/:._-])((?:claude|gemini|gpt|grok|glm|qwen|minimax|kimi|deepseek|llama|gemma|nova|mistral|ministral|pixtral|codestral|devstral|magistral|ernie|doubao|seed|aion|olmo|molmo|nemotron|palmyra|command|codex|coder|o[1345])[-a-z0-9.]+(?:[-_/][a-z0-9.]+)*)(?::|$)/i, @@ -96,28 +89,22 @@ function buildCanonicalSuffixAliasMap(references: ReadonlyMap return new Map([...aliases.entries()].map(([alias, referenceId]) => [normalizeCanonicalIdKey(alias), referenceId])); } -function createCanonicalReferenceData(): CanonicalReferenceData { - if (referenceDataCache) { - return referenceDataCache; - } +/** + * Build canonical reference data from a model catalog (typically the bundled + * models). Pure: callers are responsible for memoizing the result — the + * canonical index keeps per-reference resolution caches internally. + */ +export function buildCanonicalReferenceData(models: Iterable>): CanonicalReferenceData { const references = new Map>(); - for (const provider of getBundledProviders()) { - for (const model of getBundledModels(provider as Parameters[0])) { - const candidate = model as Model; - const existing = references.get(candidate.id); - if (shouldReplaceReference(existing, candidate)) { - references.set(candidate.id, candidate); - } + for (const candidate of models) { + const existing = references.get(candidate.id); + if (shouldReplaceReference(existing, candidate)) { + references.set(candidate.id, candidate); } } const officialIds = new Set(references.keys()); const suffixAliases = buildCanonicalSuffixAliasMap(references); - referenceDataCache = { - references: Object.freeze(references) as Map>, - officialIds: Object.freeze(officialIds) as Set, - suffixAliases: Object.freeze(suffixAliases) as Map, - }; - return referenceDataCache; + return { references, officialIds, suffixAliases }; } function normalizeSelectorKey(selector: string): string { @@ -448,7 +435,7 @@ function getWrapperCanonicalCandidates(candidate: string): string[] { return [...results]; } -function getAnthropicAliasOfficial(candidate: string, officialIds: Set): string | undefined { +function getAnthropicAliasOfficial(candidate: string, officialIds: ReadonlySet): string | undefined { const reordered = reorderAnthropicFamily(candidate); if (!reordered) { return undefined; @@ -504,7 +491,7 @@ function parseClaudeFamilyVersionSegments(candidate: string, prefix: string): nu const CLAUDE_FAMILY_ALIAS_PATTERN = /^(?:anthropic\/)?(claude(?:-\d(?:[.-]\d+)?)?-(?:haiku|opus|sonnet))(?:-latest)?$/i; const CLAUDE_DATE_SUFFIX_PATTERN = /-\d{8}(?:$|-)/i; -function getClaudeFamilyAliasOfficial(candidate: string, officialIds: Set): string | undefined { +function getClaudeFamilyAliasOfficial(candidate: string, officialIds: ReadonlySet): string | undefined { const match = CLAUDE_FAMILY_ALIAS_PATTERN.exec(candidate); if (!match?.[1]) { return undefined; @@ -826,18 +813,26 @@ function compareCanonicalVariants(left: CanonicalModelVariant, right: CanonicalM export function buildCanonicalModelIndex( models: readonly Model[], + reference: CanonicalReferenceData, equivalence?: ModelEquivalenceConfig, ): CanonicalModelIndex { - const referenceData = createCanonicalReferenceData(); + const referenceData = reference; const compiledEquivalence = compileEquivalenceConfig(equivalence); const byId = new Map(); const bySelector = new Map(); - const compiledWithCache = compiledEquivalence as CompiledEquivalenceConfigWithCache; - let modelCache = compiledWithCache[kModelResolutionCache]; + // Resolution results depend on (model, equivalence, reference); cache them on + // the reference data keyed by the compiled equivalence config so neither a + // different reference dataset nor a different override set can poison entries. + let caches = referenceData[kResolutionCaches]; + if (!caches) { + caches = new WeakMap(); + referenceData[kResolutionCaches] = caches; + } + let modelCache = caches.get(compiledEquivalence); if (!modelCache) { modelCache = new Map(); - compiledWithCache[kModelResolutionCache] = modelCache; + caches.set(compiledEquivalence, modelCache); } for (const model of models) { diff --git a/packages/coding-agent/src/config/model-id-affixes.ts b/packages/catalog/src/identity/id.ts similarity index 100% rename from packages/coding-agent/src/config/model-id-affixes.ts rename to packages/catalog/src/identity/id.ts diff --git a/packages/catalog/src/identity/index.ts b/packages/catalog/src/identity/index.ts new file mode 100644 index 000000000..cf16518b8 --- /dev/null +++ b/packages/catalog/src/identity/index.ts @@ -0,0 +1,8 @@ +export * from "./bundled"; +export * from "./classify"; +export * from "./equivalence"; +export * from "./id"; +export * from "./markers"; +export * from "./priority"; +export * from "./reference"; +export * from "./selection"; diff --git a/packages/catalog/src/identity/markers.ts b/packages/catalog/src/identity/markers.ts new file mode 100644 index 000000000..736a50f7f --- /dev/null +++ b/packages/catalog/src/identity/markers.ts @@ -0,0 +1,49 @@ +/** + * Trailing-marker vocabulary shared by canonical-id resolution and + * proxy-reference lookup. A "marker" is a routing/quantization/effort suffix + * a reseller or aggregator appends to an upstream model id + * (`-thinking`, `:nitro`, `-fp8`, …) that does not change model identity. + */ +const TRAILING_MARKERS = [ + "thinking", + "customtools", + "high", + "low", + "medium", + "minimal", + "xhigh", + "free", + "cloud", + "exacto", + "nitro", + "original", + "optimized", + "nvfp4", + "fp8", + "fp4", + "bf16", + "int8", + "int4", +] as const; + +/** + * Markers treated as identity-preserving ONLY when recovering bundled metadata + * for a proxied model id, never during canonical-id coalescing: Perplexity's + * `sonar-pro-search` is a distinct model from `sonar-pro`, so canonical + * resolution must not strip `search`, while a proxy id like + * `claude-opus-4-6-search` should still inherit the upstream pricing/limits. + */ +const REFERENCE_ONLY_TRAILING_MARKERS = ["search"] as const; + +function buildTrailingMarkerPattern(markers: readonly string[]): RegExp { + return new RegExp(`[-:](?:${markers.join("|")})$`, "i"); +} + +/** Marker pattern used by canonical-id resolution (`search` excluded). */ +export const CANONICAL_TRAILING_MARKER_PATTERN = buildTrailingMarkerPattern(TRAILING_MARKERS); + +/** Marker pattern used by proxy-reference lookup (`search` included). */ +export const REFERENCE_TRAILING_MARKER_PATTERN = buildTrailingMarkerPattern([ + ...TRAILING_MARKERS, + ...REFERENCE_ONLY_TRAILING_MARKERS, +]); diff --git a/packages/coding-agent/src/config/model-provider-priority.ts b/packages/catalog/src/identity/priority.ts similarity index 100% rename from packages/coding-agent/src/config/model-provider-priority.ts rename to packages/catalog/src/identity/priority.ts diff --git a/packages/catalog/src/identity/reference.ts b/packages/catalog/src/identity/reference.ts new file mode 100644 index 000000000..00ad6c4f1 --- /dev/null +++ b/packages/catalog/src/identity/reference.ts @@ -0,0 +1,134 @@ +/** + * Proxy/reseller reference lookup: given a custom model id served through a + * proxy (`[Kiro] claude-opus-4-8`, `gpt-5.4:cloud`, `vendor/claude-sonnet-4-6-thinking`), + * find the bundled upstream model so missing pricing/capability metadata can be + * inherited while keeping the custom transport. + * + * Kept separate from canonical-id resolution (`./equivalence`): this lookup + * may strip `search`-style markers and prefers cache-pricing-complete + * references, both of which would be wrong for canonical coalescing. + */ +import type { Api, Model } from "../types"; +import { getBracketStrippedModelIdCandidates, getLongestModelLikeIdSegment, getModelLikeIdSegments } from "./id"; +import { REFERENCE_TRAILING_MARKER_PATTERN } from "./markers"; + +export interface ModelReferenceIndex { + exact: Map>; + suffixAlias: Map>; +} + +// Custom provider entries often front a known upstream model through a local proxy. +// Prefer the reference with the largest limits and complete cache pricing, then +// first-party OpenAI entries. +function shouldReplaceReference(existing: Model | undefined, candidate: Model): boolean { + if (!existing) return true; + if (candidate.contextWindow !== existing.contextWindow) { + return candidate.contextWindow > existing.contextWindow; + } + if (candidate.maxTokens !== existing.maxTokens) { + return candidate.maxTokens > existing.maxTokens; + } + const existingHasCachePricing = existing.cost.cacheRead > 0 || existing.cost.cacheWrite > 0; + const candidateHasCachePricing = candidate.cost.cacheRead > 0 || candidate.cost.cacheWrite > 0; + if (candidateHasCachePricing !== existingHasCachePricing) { + return candidateHasCachePricing; + } + return existing.provider !== "openai" && candidate.provider === "openai"; +} + +function normalizeReferenceKey(value: string): string { + return value.trim().toLowerCase(); +} + +/** + * Build a reference index from a model catalog (typically the bundled models). + * Pure: callers are responsible for memoizing the result. + */ +export function buildModelReferenceIndex(models: Iterable>): ModelReferenceIndex { + const exact = new Map>(); + for (const candidate of models) { + const key = normalizeReferenceKey(candidate.id); + if (shouldReplaceReference(exact.get(key), candidate)) { + exact.set(key, candidate); + } + } + return { exact, suffixAlias: buildSuffixAliasMap(exact) }; +} + +function buildSuffixAliasMap(exactReferences: ReadonlyMap>): Map> { + const aliases = new Map>(); + for (const reference of exactReferences.values()) { + const slashIndex = reference.id.lastIndexOf("/"); + if (slashIndex === -1) { + continue; + } + const suffix = reference.id.slice(slashIndex + 1); + const alias = getLongestModelLikeIdSegment(suffix); + if (!alias) { + continue; + } + if (shouldReplaceReference(aliases.get(alias), reference)) { + aliases.set(alias, reference); + } + } + return aliases; +} + +function stripReferenceTrailingMarker(candidate: string): string | undefined { + const match = REFERENCE_TRAILING_MARKER_PATTERN.exec(candidate); + return match ? candidate.slice(0, match.index) : undefined; +} + +function getReferenceCandidateIds(modelId: string): string[] { + const candidates = new Set(); + const queue = [modelId]; + for (let index = 0; index < queue.length; index += 1) { + const candidate = queue[index]?.trim(); + if (!candidate || candidates.has(candidate)) continue; + candidates.add(candidate); + + for (const stripped of getBracketStrippedModelIdCandidates(candidate)) { + queue.push(stripped); + } + for (const segment of getModelLikeIdSegments(candidate)) { + queue.push(segment); + } + + for (const suffix of [":cloud", "-cloud"] as const) { + if (candidate.toLowerCase().endsWith(suffix)) { + queue.push(candidate.slice(0, -suffix.length)); + } + } + + const slashIndex = candidate.lastIndexOf("/"); + if (slashIndex !== -1) { + queue.push(candidate.slice(slashIndex + 1)); + } + + const colonToDash = candidate.replace(/:/g, "-"); + if (colonToDash !== candidate) { + queue.push(colonToDash); + } + + const lowercased = candidate.toLowerCase(); + if (lowercased !== candidate) { + queue.push(lowercased); + } + + const strippedMarker = stripReferenceTrailingMarker(candidate); + if (strippedMarker) { + queue.push(strippedMarker); + } + } + return [...candidates]; +} + +/** Resolve a (possibly proxied/affixed) model id to its bundled upstream reference. */ +export function resolveModelReference(modelId: string, index: ModelReferenceIndex): Model | undefined { + for (const candidate of getReferenceCandidateIds(modelId)) { + const key = normalizeReferenceKey(candidate); + const reference = index.exact.get(key) ?? index.suffixAlias.get(key); + if (reference) return reference; + } + return undefined; +} diff --git a/packages/catalog/src/identity/selection.ts b/packages/catalog/src/identity/selection.ts new file mode 100644 index 000000000..4c0256c0e --- /dev/null +++ b/packages/catalog/src/identity/selection.ts @@ -0,0 +1,65 @@ +/** + * Canonical-variant selection: pick the preferred variant of a canonical + * model record given caller-supplied provider and candidate orderings. + */ +import type { Api, Model } from "../types"; +import { type CanonicalModelVariant, formatCanonicalVariantSelector } from "./equivalence"; + +export interface CanonicalVariantPreferences { + /** Lowercased provider id → rank (lower wins). */ + providerRank: ReadonlyMap; + /** Variant selector (`provider/id`) → candidate-list position (lower wins). */ + modelOrder: ReadonlyMap; +} + +/** Selector → index map over an ordered candidate list, for `modelOrder` tiebreaks. */ +export function buildCanonicalModelOrder(candidates: readonly Model[]): Map { + const modelOrder = new Map(); + for (let index = 0; index < candidates.length; index += 1) { + modelOrder.set(formatCanonicalVariantSelector(candidates[index]!), index); + } + return modelOrder; +} + +const SOURCE_RANK: Record = { + override: 1, + bundled: 1, + heuristic: 2, + fallback: 3, +}; + +/** + * Pick the preferred variant. Sort order: configured provider rank → + * exact-id match → variant source (override/bundled > heuristic > fallback) + * → shorter id → candidate-list order. + */ +export function resolveCanonicalVariant( + variants: readonly CanonicalModelVariant[], + preferences: CanonicalVariantPreferences, +): CanonicalModelVariant | undefined { + if (variants.length === 0) { + return undefined; + } + const { providerRank, modelOrder } = preferences; + return [...variants].sort((left, right) => { + const leftProviderRank = providerRank.get(left.model.provider.toLowerCase()) ?? Number.MAX_SAFE_INTEGER; + const rightProviderRank = providerRank.get(right.model.provider.toLowerCase()) ?? Number.MAX_SAFE_INTEGER; + if (leftProviderRank !== rightProviderRank) { + return leftProviderRank - rightProviderRank; + } + const leftExact = left.model.id === left.canonicalId ? 0 : 1; + const rightExact = right.model.id === right.canonicalId ? 0 : 1; + if (leftExact !== rightExact) { + return leftExact - rightExact; + } + if (SOURCE_RANK[left.source] !== SOURCE_RANK[right.source]) { + return SOURCE_RANK[left.source] - SOURCE_RANK[right.source]; + } + if (left.model.id.length !== right.model.id.length) { + return left.model.id.length - right.model.id.length; + } + const leftOrder = modelOrder.get(left.selector) ?? Number.MAX_SAFE_INTEGER; + const rightOrder = modelOrder.get(right.selector) ?? Number.MAX_SAFE_INTEGER; + return leftOrder - rightOrder; + })[0]; +} diff --git a/packages/catalog/src/index.ts b/packages/catalog/src/index.ts new file mode 100644 index 000000000..e6fab778f --- /dev/null +++ b/packages/catalog/src/index.ts @@ -0,0 +1,15 @@ +export * from "./compat/openai"; +export * from "./discovery"; +export * from "./effort"; +export * from "./fireworks-model-id"; +export * from "./identity"; +export * from "./model-cache"; +export * from "./model-manager"; +export * from "./model-thinking"; +export * from "./models"; +export * from "./provider-models"; +export * from "./types"; +export * from "./utils"; +export * from "./wire/codex"; +export * from "./wire/gemini-headers"; +export * from "./wire/github-copilot"; diff --git a/packages/ai/src/model-cache.ts b/packages/catalog/src/model-cache.ts similarity index 100% rename from packages/ai/src/model-cache.ts rename to packages/catalog/src/model-cache.ts diff --git a/packages/ai/src/model-manager.ts b/packages/catalog/src/model-manager.ts similarity index 100% rename from packages/ai/src/model-manager.ts rename to packages/catalog/src/model-manager.ts diff --git a/packages/ai/src/model-thinking.ts b/packages/catalog/src/model-thinking.ts similarity index 84% rename from packages/ai/src/model-thinking.ts rename to packages/catalog/src/model-thinking.ts index 912099aac..bd0e0bcce 100644 --- a/packages/ai/src/model-thinking.ts +++ b/packages/catalog/src/model-thinking.ts @@ -1,5 +1,18 @@ +import { resolveOpenAICompat } from "./compat/openai"; import { Effort, THINKING_EFFORTS } from "./effort"; -import { resolveOpenAICompat } from "./providers/openai-completions-compat"; +import { + type AnthropicModel, + bareModelId, + type GeminiModel, + isFableOrMythos, + type OpenAIModel, + type OpenAIVariant, + type ParsedModel, + parseAnthropicModel, + parseKnownModel, + semverEqual, + semverGte, +} from "./identity/classify"; import type { Api, Model as ApiModel, ThinkingConfig } from "./types"; const DEFAULT_REASONING_EFFORTS: readonly Effort[] = [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High]; @@ -16,16 +29,6 @@ const GPT_5_2_PLUS_EFFORTS: readonly Effort[] = [Effort.Low, Effort.Medium, Effo const GPT_5_1_CODEX_MINI_EFFORTS: readonly Effort[] = [Effort.Medium, Effort.High]; const CLOUDFLARE_AI_GATEWAY_BASE_URL = "https://gateway.ai.cloudflare.com/v1///anthropic"; -type SemVer = { - major: number; - minor: number; - patch: number; -}; - -type GeminiKind = "pro" | "flash"; -type AnthropicKind = "opus" | "sonnet" | "fable" | "mythos"; -type OpenAIVariant = "base" | "codex" | "codex-max" | "codex-mini" | "codex-spark" | "mini" | "max" | "nano"; - const CODEX_GPT_5_4_PRIORITY_BY_VARIANT: Partial> = { base: 0, mini: 1, @@ -40,31 +43,6 @@ const COPILOT_GENERATED_LIMITS: Record( * - Thinking content is omitted by default (needs display: "summarized") */ export function hasOpus47ApiRestrictions(modelId: string): boolean { - const parsed = parseAnthropicModel(getCanonicalModelId(modelId)); + const parsed = parseAnthropicModel(bareModelId(modelId)); if (!parsed) return false; return (parsed.kind === "opus" && semverGte(parsed.version, "4.7")) || isFableOrMythos(parsed.kind); } @@ -360,20 +338,16 @@ export function hasOpus47ApiRestrictions(modelId: string): boolean { * @see https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages */ export function supportsMidConversationSystemMessages(modelId: string): boolean { - const parsed = parseAnthropicModel(getCanonicalModelId(modelId)); + const parsed = parseAnthropicModel(bareModelId(modelId)); if (!parsed) return false; return (parsed.kind === "opus" && semverGte(parsed.version, "4.8")) || isFableOrMythos(parsed.kind); } export function isAnthropicFableOrMythosModel(modelId: string): boolean { - const parsed = parseAnthropicModel(getCanonicalModelId(modelId)); + const parsed = parseAnthropicModel(bareModelId(modelId)); return parsed !== null && isFableOrMythos(parsed.kind); } -function isFableOrMythos(kind: AnthropicKind): boolean { - return kind === "fable" || kind === "mythos"; -} - function isOpenRouterAnthropicAdaptiveReasoningModel( parsedModel: AnthropicModel, model: ApiModel, @@ -673,98 +647,3 @@ function inferThinkingControlMode( return "effort"; } } - -function parseKnownModel(modelId: string): ParsedModel { - const canonicalId = getCanonicalModelId(modelId); - return ( - parseGeminiModel(canonicalId) ?? - parseAnthropicModel(canonicalId) ?? - parseOpenAIModel(canonicalId) ?? { family: "unknown", id: canonicalId } - ); -} - -const GEMINI_SUFFIX = "-preview"; -function parseGeminiModel(modelId: string): GeminiModel | null { - if (modelId.endsWith(GEMINI_SUFFIX)) { - modelId = modelId.slice(0, -GEMINI_SUFFIX.length); - } - const match = /gemini-(\d+(?:\.\d+){0,2})-(pro|flash)\b/.exec(modelId); - if (!match) { - return null; - } - const version = parseSemVer(match[1]); - if (!version) { - return null; - } - return { family: "gemini", kind: match[2] as GeminiKind, version }; -} - -function parseAnthropicModel(modelId: string): AnthropicModel | null { - const match = /claude-(opus|sonnet|fable|mythos)-(\d{1,2}(?:[.-]\d{1,2}){0,2})\b/.exec(modelId); - if (!match) { - return null; - } - const version = parseSemVer(match[2]); - if (!version) { - return null; - } - return { family: "anthropic", kind: match[1] as AnthropicKind, version }; -} - -function parseOpenAIModel(modelId: string): OpenAIModel | null { - const match = /gpt-(\d+(?:\.\d+){0,2})(?:-(codex-spark|codex-mini|codex-max|codex|mini|max|nano))?\b/.exec(modelId); - if (!match) { - return null; - } - const version = parseSemVer(match[1]); - if (!version) { - return null; - } - return { family: "openai", variant: (match[2] as OpenAIVariant | undefined) ?? "base", version }; -} - -function createSemVer(major: number, minor: number, patch = 0): SemVer { - return { major, minor, patch }; -} - -// extend this table if we need anything more than 9.10 -const precomputeTable: Record = {}; -for (let major = 0; major <= 9; major++) { - for (let minor = 0; minor <= 10; minor++) { - const version = createSemVer(major, minor, 0); - precomputeTable[`${major}.${minor}`] = version; - precomputeTable[`${major}-${minor}`] = version; - } - precomputeTable[`${major}`] = createSemVer(major, 0, 0); -} - -function parseSemVer(version: string): SemVer | null { - return precomputeTable[version] ?? null; -} - -function semverGte(left: SemVer | string, right: SemVer | string): boolean { - return compareSemVer(left, right) >= 0; -} - -function semverEqual(left: SemVer | string, right: SemVer | string): boolean { - return compareSemVer(left, right) === 0; -} - -function compareSemVer(left: SemVer | string | null, right: SemVer | string | null): number { - left = typeof left === "string" ? parseSemVer(left) : left; - right = typeof right === "string" ? parseSemVer(right) : right; - if (!left || !right) return (left ? 1 : 0) - (right ? 1 : 0); - - if (left.major !== right.major) { - return left.major - right.major; - } - if (left.minor !== right.minor) { - return left.minor - right.minor; - } - return left.patch - right.patch; -} - -function getCanonicalModelId(modelId: string): string { - const p = modelId.lastIndexOf("/"); - return p !== -1 ? modelId.slice(p + 1) : modelId; -} diff --git a/packages/ai/src/models.json b/packages/catalog/src/models.json similarity index 99% rename from packages/ai/src/models.json rename to packages/catalog/src/models.json index 54ee69e6f..9822054af 100644 --- a/packages/ai/src/models.json +++ b/packages/catalog/src/models.json @@ -7817,6 +7817,31 @@ "contextWindow": 200000, "maxTokens": 4096 }, + "eu.anthropic.claude-fable-5": { + "id": "eu.anthropic.claude-fable-5", + "name": "Claude Fable 5 (EU)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 11, + "output": 55, + "cacheRead": 1.1, + "cacheWrite": 13.75 + }, + "contextWindow": 1000000, + "maxTokens": 128000, + "thinking": { + "mode": "anthropic-adaptive", + "minLevel": "minimal", + "maxLevel": "xhigh" + } + }, "eu.anthropic.claude-haiku-4-5-20251001-v1:0": { "id": "eu.anthropic.claude-haiku-4-5-20251001-v1:0", "name": "Claude Haiku 4.5 (EU)", @@ -8092,6 +8117,31 @@ "maxLevel": "high" } }, + "global.anthropic.claude-fable-5": { + "id": "global.anthropic.claude-fable-5", + "name": "Claude Fable 5 (Global)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 10, + "output": 50, + "cacheRead": 1, + "cacheWrite": 12.5 + }, + "contextWindow": 1000000, + "maxTokens": 128000, + "thinking": { + "mode": "anthropic-adaptive", + "minLevel": "minimal", + "maxLevel": "xhigh" + } + }, "global.anthropic.claude-haiku-4-5-20251001-v1:0": { "id": "global.anthropic.claude-haiku-4-5-20251001-v1:0", "name": "Claude Haiku 4.5", @@ -9328,6 +9378,31 @@ "contextWindow": 200000, "maxTokens": 8192 }, + "us.anthropic.claude-fable-5": { + "id": "us.anthropic.claude-fable-5", + "name": "Claude Fable 5 (US)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 10, + "output": 50, + "cacheRead": 1, + "cacheWrite": 12.5 + }, + "contextWindow": 1000000, + "maxTokens": 128000, + "thinking": { + "mode": "anthropic-adaptive", + "minLevel": "minimal", + "maxLevel": "xhigh" + } + }, "us.anthropic.claude-haiku-4-5-20251001-v1:0": { "id": "us.anthropic.claude-haiku-4-5-20251001-v1:0", "name": "Claude Haiku 4.5 (US)", @@ -10614,6 +10689,31 @@ "contextWindow": 200000, "maxTokens": 8192 }, + "anthropic/claude-fable-5": { + "id": "anthropic/claude-fable-5", + "name": "Claude Fable 5", + "api": "anthropic-messages", + "provider": "cloudflare-ai-gateway", + "baseUrl": "https://gateway.ai.cloudflare.com/v1///anthropic", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 10, + "output": 50, + "cacheRead": 1, + "cacheWrite": 12.5 + }, + "contextWindow": 1000000, + "maxTokens": 128000, + "thinking": { + "mode": "anthropic-adaptive", + "minLevel": "minimal", + "maxLevel": "xhigh" + } + }, "anthropic/claude-haiku-4-5": { "id": "anthropic/claude-haiku-4-5", "name": "Claude Haiku 4.5 (latest)", @@ -16456,6 +16556,25 @@ } }, "kilo": { + "~anthropic/claude-fable-latest": { + "id": "~anthropic/claude-fable-latest", + "name": "Anthropic: Claude Fable Latest ($$$$)", + "api": "openai-completions", + "provider": "kilo", + "baseUrl": "https://api.kilo.ai/api/gateway", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 222222, + "maxTokens": 8888 + }, "~anthropic/claude-haiku-latest": { "id": "~anthropic/claude-haiku-latest", "name": "Anthropic Claude Haiku Latest", @@ -17113,13 +17232,14 @@ }, "anthropic/claude-fable-5": { "id": "anthropic/claude-fable-5", - "name": "Anthropic: Claude Fable 5 ($$$$)", + "name": "Claude Fable 5", "api": "openai-completions", "provider": "kilo", "baseUrl": "https://api.kilo.ai/api/gateway", - "reasoning": false, + "reasoning": true, "input": [ - "text" + "text", + "image" ], "cost": { "input": 0, @@ -17127,8 +17247,13 @@ "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": 222222, - "maxTokens": 8888 + "contextWindow": 1000000, + "maxTokens": 128000, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "xhigh" + } }, "anthropic/claude-haiku-4.5": { "id": "anthropic/claude-haiku-4.5", @@ -27661,7 +27786,7 @@ }, "anthropic/claude-fable-5": { "id": "anthropic/claude-fable-5", - "name": "Anthropic: Claude Fable 5", + "name": "Claude Fable 5", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", @@ -27684,6 +27809,25 @@ "maxLevel": "xhigh" } }, + "anthropic/claude-fable-latest": { + "id": "anthropic/claude-fable-latest", + "name": "anthropic/claude-fable-latest", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 222222, + "maxTokens": 8888 + }, "anthropic/claude-haiku-latest": { "id": "anthropic/claude-haiku-latest", "name": "anthropic/claude-haiku-latest", @@ -46913,6 +47057,31 @@ "contextWindow": 200000, "maxTokens": 8192 }, + "claude-fable-5": { + "id": "claude-fable-5", + "name": "Claude Fable 5", + "api": "anthropic-messages", + "provider": "opencode-zen", + "baseUrl": "https://opencode.ai/zen", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 10, + "output": 50, + "cacheRead": 1, + "cacheWrite": 12.5 + }, + "contextWindow": 1000000, + "maxTokens": 128000, + "thinking": { + "mode": "anthropic-adaptive", + "minLevel": "minimal", + "maxLevel": "xhigh" + } + }, "claude-haiku-4-5": { "id": "claude-haiku-4-5", "name": "Claude Haiku 4.5", @@ -48388,6 +48557,31 @@ } }, "openrouter": { + "~anthropic/claude-fable-latest": { + "id": "~anthropic/claude-fable-latest", + "name": "Anthropic: Claude Fable Latest", + "api": "openai-completions", + "provider": "openrouter", + "baseUrl": "https://openrouter.ai/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 10, + "output": 50, + "cacheRead": 1, + "cacheWrite": 12.5 + }, + "contextWindow": 1000000, + "maxTokens": 128000, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "high" + } + }, "~anthropic/claude-haiku-latest": { "id": "~anthropic/claude-haiku-latest", "name": "Anthropic Claude Haiku Latest", @@ -48878,7 +49072,7 @@ }, "anthropic/claude-fable-5": { "id": "anthropic/claude-fable-5", - "name": "Anthropic: Claude Fable 5", + "name": "Claude Fable 5", "api": "openai-completions", "provider": "openrouter", "baseUrl": "https://openrouter.ai/api/v1", @@ -55916,11 +56110,11 @@ "cost": { "input": 0.3, "output": 0.8999999999999999, - "cacheRead": 0.049999999999999996, + "cacheRead": 0.055, "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": 24000, + "maxTokens": 32768, "thinking": { "mode": "effort", "minLevel": "minimal", @@ -56015,7 +56209,7 @@ "cacheRead": 0.24, "cacheWrite": 0 }, - "contextWindow": 202752, + "contextWindow": 262144, "maxTokens": 131072, "thinking": { "mode": "effort", @@ -64858,6 +65052,37 @@ "minLevel": "minimal", "maxLevel": "high" } + }, + "mimo-v2.5-pro-ultraspeed": { + "id": "mimo-v2.5-pro-ultraspeed", + "name": "MiMo-V2.5-Pro-UltraSpeed", + "api": "openai-completions", + "provider": "xiaomi", + "baseUrl": "https://api.xiaomimimo.com/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 1.305, + "output": 2.61, + "cacheRead": 0.0108, + "cacheWrite": 0 + }, + "contextWindow": 1048576, + "maxTokens": 131072, + "compat": { + "supportsStore": false, + "thinkingFormat": "zai", + "reasoningContentField": "reasoning_content", + "requiresReasoningContentForToolCalls": true, + "allowsSyntheticReasoningContentForToolCalls": false + }, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "high" + } } }, "zai": { @@ -65243,6 +65468,31 @@ "maxLevel": "xhigh" } }, + "anthropic/claude-fable-5": { + "id": "anthropic/claude-fable-5", + "name": "Claude Fable 5", + "api": "anthropic-messages", + "provider": "zenmux", + "baseUrl": "https://zenmux.ai/api/anthropic", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 10, + "output": 50, + "cacheRead": 1, + "cacheWrite": 12.5 + }, + "contextWindow": 1000000, + "maxTokens": 128000, + "thinking": { + "mode": "anthropic-adaptive", + "minLevel": "minimal", + "maxLevel": "xhigh" + } + }, "anthropic/claude-haiku-4.5": { "id": "anthropic/claude-haiku-4.5", "name": "Claude Haiku 4.5", diff --git a/packages/ai/src/models.json.d.ts b/packages/catalog/src/models.json.d.ts similarity index 100% rename from packages/ai/src/models.json.d.ts rename to packages/catalog/src/models.json.d.ts diff --git a/packages/ai/src/models.ts b/packages/catalog/src/models.ts similarity index 100% rename from packages/ai/src/models.ts rename to packages/catalog/src/models.ts diff --git a/packages/ai/src/provider-models/bundled-references.ts b/packages/catalog/src/provider-models/bundled-references.ts similarity index 100% rename from packages/ai/src/provider-models/bundled-references.ts rename to packages/catalog/src/provider-models/bundled-references.ts diff --git a/packages/catalog/src/provider-models/descriptor-types.ts b/packages/catalog/src/provider-models/descriptor-types.ts new file mode 100644 index 000000000..97bdb0a41 --- /dev/null +++ b/packages/catalog/src/provider-models/descriptor-types.ts @@ -0,0 +1,79 @@ +import type { ModelManagerOptions } from "../model-manager"; +import type { Api, FetchImpl } from "../types"; + +/** Config passed to a provider's runtime model-manager factory. */ +export type ModelManagerConfig = { apiKey?: string; baseUrl?: string; fetch?: FetchImpl }; + +/** Catalog discovery configuration for providers that support endpoint-based model listing. */ +export interface CatalogDiscoveryConfig { + /** Human-readable name for log messages. */ + label: string; + /** + * Environment variables to check for API keys during catalog generation. + * Defaults to the entry-level `envVars` when omitted. + */ + envVars?: readonly string[]; + /** OAuth provider for credential refresh during catalog generation. */ + oauthProvider?: string; + /** When true, catalog discovery proceeds even without credentials. */ + allowUnauthenticated?: boolean; +} + +/** Unified provider descriptor used by both runtime discovery and catalog generation. */ +export interface ProviderDescriptor { + providerId: string; + createModelManagerOptions(config: ModelManagerConfig): ModelManagerOptions; + /** Preferred model ID when no explicit selection is made. */ + defaultModel: string; + /** When true, the runtime creates a model manager even without a valid API key (e.g. ollama). */ + allowUnauthenticated?: boolean; + /** When true, successful runtime discovery replaces bundled provider models instead of merging fallback-only IDs. */ + dynamicModelsAuthoritative?: boolean; + /** Catalog discovery configuration. Only providers with this field participate in generate-models.ts. */ + catalogDiscovery?: CatalogDiscoveryConfig; +} + +/** A provider descriptor that has catalog discovery configured. */ +export type CatalogProviderDescriptor = ProviderDescriptor & { catalogDiscovery: CatalogDiscoveryConfig }; + +/** Type guard for descriptors with catalog discovery. */ +export function isCatalogDescriptor(d: ProviderDescriptor): d is CatalogProviderDescriptor { + return d.catalogDiscovery != null; +} + +/** Whether catalog discovery may run without provider credentials. */ +export function allowsUnauthenticatedCatalogDiscovery(descriptor: CatalogProviderDescriptor): boolean { + return descriptor.catalogDiscovery.allowUnauthenticated ?? descriptor.allowUnauthenticated ?? false; +} + +/** + * One model provider's catalog-side description. The auth half of a provider + * (env keys, OAuth login/refresh flows) lives in `@oh-my-pi/pi-ai`'s registry; + * the catalog table below is the single source of truth for ids, default + * models, and discovery wiring. + * + * - Every entry is a member of `KnownProvider`. + * - `createModelManagerOptions` present (and not `specialModelManager`) ⇒ + * appears in `PROVIDER_DESCRIPTORS` for runtime model discovery. + * - `catalogDiscovery` present ⇒ participates in `generate-models.ts`. + */ +export interface ProviderCatalogEntry { + readonly id: string; + /** Preferred model ID when no explicit selection is made. */ + readonly defaultModel: string; + /** Environment variables consulted (in order) for the provider's runtime API-key env fallback. */ + readonly envVars?: readonly string[]; + /** Runtime model-manager factory. Omitted for catalog-only providers. */ + readonly createModelManagerOptions?: (config: ModelManagerConfig) => ModelManagerOptions; + /** When true, the runtime creates a model manager even without a valid API key. */ + readonly allowUnauthenticated?: boolean; + /** When true, successful runtime discovery replaces bundled provider models. */ + readonly dynamicModelsAuthoritative?: boolean; + /** Catalog discovery configuration for generate-models.ts. */ + readonly catalogDiscovery?: CatalogDiscoveryConfig; + /** + * Built bespoke by the coding-agent runtime (OAuth-token-driven managers); + * excluded from `PROVIDER_DESCRIPTORS` even though models are discoverable. + */ + readonly specialModelManager?: boolean; +} diff --git a/packages/catalog/src/provider-models/descriptors.ts b/packages/catalog/src/provider-models/descriptors.ts new file mode 100644 index 000000000..b4d32b462 --- /dev/null +++ b/packages/catalog/src/provider-models/descriptors.ts @@ -0,0 +1,456 @@ +/** + * The provider catalog table: one entry per chat-model provider, carrying the + * catalog half of what used to live in `@oh-my-pi/pi-ai`'s registry definitions + * (default model, runtime model-manager factory, discovery wiring). The auth + * half (env keys, OAuth login/refresh) stays in the pi-ai registry, which + * type-checks itself against `KnownProvider` from this table. + */ +import type { ModelManagerConfig, ProviderCatalogEntry, ProviderDescriptor } from "./descriptor-types"; +import { googleModelManagerOptions, googleVertexModelManagerOptions } from "./google"; +import { ollamaCloudModelManagerOptions } from "./ollama"; +import { + aimlApiModelManagerOptions, + alibabaCodingPlanModelManagerOptions, + anthropicModelManagerOptions, + cerebrasModelManagerOptions, + cloudflareAiGatewayModelManagerOptions, + deepseekModelManagerOptions, + firepassModelManagerOptions, + fireworksModelManagerOptions, + githubCopilotModelManagerOptions, + groqModelManagerOptions, + huggingfaceModelManagerOptions, + kiloModelManagerOptions, + kimiCodeModelManagerOptions, + litellmModelManagerOptions, + lmStudioModelManagerOptions, + mistralModelManagerOptions, + moonshotModelManagerOptions, + nanoGptModelManagerOptions, + nvidiaModelManagerOptions, + ollamaModelManagerOptions, + openaiModelManagerOptions, + opencodeGoModelManagerOptions, + opencodeZenModelManagerOptions, + openrouterModelManagerOptions, + qianfanModelManagerOptions, + qwenPortalModelManagerOptions, + syntheticModelManagerOptions, + togetherModelManagerOptions, + veniceModelManagerOptions, + vercelAiGatewayModelManagerOptions, + vllmModelManagerOptions, + waferPassModelManagerOptions, + waferServerlessModelManagerOptions, + xaiModelManagerOptions, + xaiOAuthModelManagerOptions, + xiaomiModelManagerOptions, + zenmuxModelManagerOptions, + zhipuCodingPlanModelManagerOptions, +} from "./openai-compat"; +import { cursorModelManagerOptions, zaiModelManagerOptions } from "./special"; + +export const CATALOG_PROVIDERS = [ + { + id: "aimlapi", + defaultModel: "gpt-4o", + envVars: ["AIMLAPI_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => aimlApiModelManagerOptions(config), + dynamicModelsAuthoritative: true, + catalogDiscovery: { label: "AIML API" }, + }, + { + id: "alibaba-coding-plan", + defaultModel: "qwen3.5-plus", + envVars: ["ALIBABA_CODING_PLAN_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => alibabaCodingPlanModelManagerOptions(config), + catalogDiscovery: { label: "Alibaba Coding Plan" }, + }, + { + id: "amazon-bedrock", + defaultModel: "us.anthropic.claude-opus-4-6-v1", + }, + { + id: "anthropic", + defaultModel: "claude-opus-4-6", + createModelManagerOptions: (config: ModelManagerConfig) => anthropicModelManagerOptions(config), + }, + { + id: "cerebras", + defaultModel: "zai-glm-4.6", + envVars: ["CEREBRAS_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => cerebrasModelManagerOptions(config), + catalogDiscovery: { label: "Cerebras" }, + }, + { + id: "cloudflare-ai-gateway", + defaultModel: "claude-sonnet-4-5", + envVars: ["CLOUDFLARE_AI_GATEWAY_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => cloudflareAiGatewayModelManagerOptions(config), + catalogDiscovery: { label: "Cloudflare AI Gateway" }, + }, + { + id: "cursor", + defaultModel: "claude-sonnet-4-6", + envVars: ["CURSOR_ACCESS_TOKEN"], + createModelManagerOptions: (config: ModelManagerConfig) => cursorModelManagerOptions(config), + catalogDiscovery: { label: "Cursor", envVars: ["CURSOR_API_KEY"], oauthProvider: "cursor" }, + }, + { + id: "deepseek", + defaultModel: "deepseek-v4-pro", + envVars: ["DEEPSEEK_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => deepseekModelManagerOptions(config), + catalogDiscovery: { label: "DeepSeek" }, + }, + { + id: "firepass", + defaultModel: "kimi-k2.6-turbo", + envVars: ["FIREPASS_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => firepassModelManagerOptions(config), + }, + { + id: "fireworks", + defaultModel: "kimi-k2.6", + envVars: ["FIREWORKS_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => fireworksModelManagerOptions(config), + catalogDiscovery: { label: "Fireworks" }, + }, + { + id: "github-copilot", + defaultModel: "gpt-4o", + envVars: ["COPILOT_GITHUB_TOKEN"], + createModelManagerOptions: (config: ModelManagerConfig) => githubCopilotModelManagerOptions(config), + }, + { + id: "gitlab-duo", + defaultModel: "duo-chat-sonnet-4-5", + envVars: ["GITLAB_TOKEN"], + }, + { + id: "google", + defaultModel: "gemini-2.5-pro", + envVars: ["GEMINI_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => googleModelManagerOptions(config), + }, + { + id: "google-antigravity", + defaultModel: "gemini-3-pro-high", + specialModelManager: true, + }, + { + id: "google-gemini-cli", + defaultModel: "gemini-2.5-pro", + specialModelManager: true, + }, + { + id: "google-vertex", + defaultModel: "gemini-3-pro-preview", + createModelManagerOptions: (config: ModelManagerConfig) => googleVertexModelManagerOptions(config), + allowUnauthenticated: true, + }, + { + id: "groq", + defaultModel: "openai/gpt-oss-120b", + envVars: ["GROQ_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => groqModelManagerOptions(config), + }, + { + id: "huggingface", + defaultModel: "deepseek-ai/DeepSeek-R1", + envVars: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"], + createModelManagerOptions: (config: ModelManagerConfig) => huggingfaceModelManagerOptions(config), + catalogDiscovery: { label: "Hugging Face" }, + }, + { + id: "kilo", + defaultModel: "anthropic/claude-sonnet-4.5", + envVars: ["KILO_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => kiloModelManagerOptions(config), + catalogDiscovery: { label: "Kilo Gateway", allowUnauthenticated: true }, + }, + { + id: "kimi-code", + defaultModel: "kimi-k2.5", + createModelManagerOptions: (config: ModelManagerConfig) => kimiCodeModelManagerOptions(config), + catalogDiscovery: { label: "Kimi Code", envVars: ["KIMI_API_KEY"] }, + }, + { + id: "litellm", + defaultModel: "claude-opus-4-6", + envVars: ["LITELLM_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => litellmModelManagerOptions(config), + catalogDiscovery: { label: "LiteLLM", allowUnauthenticated: true }, + }, + { + id: "lm-studio", + defaultModel: "llama-3-8b", + envVars: ["LM_STUDIO_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => lmStudioModelManagerOptions(config), + allowUnauthenticated: true, + }, + { + id: "minimax", + defaultModel: "MiniMax-M2.5", + envVars: ["MINIMAX_API_KEY"], + }, + { + id: "minimax-code", + defaultModel: "MiniMax-M2.5", + envVars: ["MINIMAX_CODE_API_KEY"], + }, + { + id: "minimax-code-cn", + defaultModel: "MiniMax-M2.5", + envVars: ["MINIMAX_CODE_CN_API_KEY"], + }, + { + id: "mistral", + defaultModel: "devstral-medium-latest", + envVars: ["MISTRAL_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => mistralModelManagerOptions(config), + }, + { + id: "moonshot", + defaultModel: "kimi-k2.5", + envVars: ["MOONSHOT_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => moonshotModelManagerOptions(config), + catalogDiscovery: { label: "Moonshot" }, + }, + { + id: "nanogpt", + defaultModel: "openai/gpt-5.4", + envVars: ["NANO_GPT_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => nanoGptModelManagerOptions(config), + catalogDiscovery: { label: "NanoGPT" }, + }, + { + id: "nvidia", + defaultModel: "nvidia/llama-3.1-nemotron-70b-instruct", + envVars: ["NVIDIA_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => nvidiaModelManagerOptions(config), + catalogDiscovery: { label: "NVIDIA" }, + }, + { + id: "ollama", + defaultModel: "gpt-oss:20b", + envVars: ["OLLAMA_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => ollamaModelManagerOptions(config), + allowUnauthenticated: true, + }, + { + id: "ollama-cloud", + defaultModel: "gpt-oss:120b", + envVars: ["OLLAMA_CLOUD_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => ollamaCloudModelManagerOptions(config), + catalogDiscovery: { label: "Ollama Cloud", oauthProvider: "ollama-cloud" }, + }, + { + id: "openai", + defaultModel: "gpt-5.4", + envVars: ["OPENAI_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => openaiModelManagerOptions(config), + }, + { + id: "openai-codex", + defaultModel: "gpt-5.4", + envVars: ["OPENAI_CODEX_OAUTH_TOKEN"], + specialModelManager: true, + }, + { + id: "opencode-go", + defaultModel: "kimi-k2.5", + envVars: ["OPENCODE_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => opencodeGoModelManagerOptions(config), + }, + { + id: "opencode-zen", + defaultModel: "claude-sonnet-4-6", + envVars: ["OPENCODE_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => opencodeZenModelManagerOptions(config), + }, + { + id: "openrouter", + defaultModel: "openai/gpt-5.4", + envVars: ["OPENROUTER_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => openrouterModelManagerOptions(config), + catalogDiscovery: { label: "OpenRouter", allowUnauthenticated: true }, + }, + { + id: "qianfan", + defaultModel: "deepseek-v3.2", + envVars: ["QIANFAN_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => qianfanModelManagerOptions(config), + catalogDiscovery: { label: "Qianfan" }, + }, + { + id: "qwen-portal", + defaultModel: "coder-model", + envVars: ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => qwenPortalModelManagerOptions(config), + catalogDiscovery: { + label: "Qwen Portal", + oauthProvider: "qwen-portal", + }, + }, + { + id: "synthetic", + defaultModel: "hf:zai-org/GLM-5.1", + envVars: ["SYNTHETIC_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => syntheticModelManagerOptions(config), + dynamicModelsAuthoritative: true, + catalogDiscovery: { label: "Synthetic" }, + }, + { + id: "together", + defaultModel: "moonshotai/Kimi-K2.5", + envVars: ["TOGETHER_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => togetherModelManagerOptions(config), + catalogDiscovery: { label: "Together" }, + }, + { + id: "venice", + defaultModel: "llama-3.3-70b", + envVars: ["VENICE_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => veniceModelManagerOptions(config), + catalogDiscovery: { label: "Venice", allowUnauthenticated: true }, + }, + { + id: "vercel-ai-gateway", + defaultModel: "anthropic/claude-sonnet-4-6", + envVars: ["AI_GATEWAY_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => vercelAiGatewayModelManagerOptions(config), + catalogDiscovery: { + label: "Vercel AI Gateway", + envVars: ["VERCEL_AI_GATEWAY_API_KEY"], + allowUnauthenticated: true, + }, + }, + { + id: "vllm", + defaultModel: "gpt-oss-20b", + envVars: ["VLLM_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => vllmModelManagerOptions(config), + catalogDiscovery: { label: "vLLM", allowUnauthenticated: true }, + }, + { + id: "wafer-pass", + defaultModel: "GLM-5.1", + envVars: ["WAFER_PASS_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => waferPassModelManagerOptions(config), + catalogDiscovery: { label: "Wafer Pass", oauthProvider: "wafer-pass" }, + }, + { + id: "wafer-serverless", + defaultModel: "GLM-5.1", + envVars: ["WAFER_SERVERLESS_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => waferServerlessModelManagerOptions(config), + catalogDiscovery: { + label: "Wafer Serverless", + oauthProvider: "wafer-serverless", + }, + }, + { + id: "xai", + defaultModel: "grok-4-fast-non-reasoning", + envVars: ["XAI_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => xaiModelManagerOptions(config), + }, + { + id: "xai-oauth", + defaultModel: "grok-4.3", + envVars: ["XAI_OAUTH_TOKEN", "XAI_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => xaiOAuthModelManagerOptions(config), + catalogDiscovery: { + label: "xAI Grok OAuth (SuperGrok)", + oauthProvider: "xai-oauth", + }, + }, + { + id: "xiaomi", + defaultModel: "mimo-v2-flash", + envVars: ["XIAOMI_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => xiaomiModelManagerOptions(config), + catalogDiscovery: { label: "Xiaomi" }, + }, + { + id: "xiaomi-token-plan-ams", + defaultModel: "mimo-v2.5", + envVars: ["XIAOMI_TOKEN_PLAN_AMS_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => + xiaomiModelManagerOptions({ ...config, providerId: "xiaomi-token-plan-ams", tokenPlanRegion: "ams" }), + }, + { + id: "xiaomi-token-plan-cn", + defaultModel: "mimo-v2.5", + envVars: ["XIAOMI_TOKEN_PLAN_CN_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => + xiaomiModelManagerOptions({ ...config, providerId: "xiaomi-token-plan-cn", tokenPlanRegion: "cn" }), + }, + { + id: "xiaomi-token-plan-sgp", + defaultModel: "mimo-v2.5", + envVars: ["XIAOMI_TOKEN_PLAN_SGP_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => + xiaomiModelManagerOptions({ ...config, providerId: "xiaomi-token-plan-sgp", tokenPlanRegion: "sgp" }), + }, + { + id: "zai", + defaultModel: "glm-5.1", + envVars: ["ZAI_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => zaiModelManagerOptions(config), + catalogDiscovery: { label: "zAI" }, + }, + { + id: "zenmux", + defaultModel: "anthropic/claude-opus-4.6", + envVars: ["ZENMUX_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => zenmuxModelManagerOptions(config), + catalogDiscovery: { label: "ZenMux" }, + }, + { + id: "zhipu-coding-plan", + defaultModel: "glm-5.1", + envVars: ["ZHIPU_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => zhipuCodingPlanModelManagerOptions(config), + catalogDiscovery: { label: "Zhipu Coding Plan" }, + }, +] as const satisfies readonly ProviderCatalogEntry[]; + +/** Chat-model providers — every entry in the catalog table. */ +export type KnownProvider = (typeof CATALOG_PROVIDERS)[number]["id"]; + +/** + * Runtime model-discovery descriptors: every catalog provider that exposes a + * standard model-manager factory. Special-managed providers + * (`google-antigravity`/`google-gemini-cli`/`openai-codex`) are built bespoke in + * the coding-agent runtime and are excluded here. + */ +const CATALOG_ENTRY_LIST: readonly ProviderCatalogEntry[] = CATALOG_PROVIDERS; + +export const PROVIDER_DESCRIPTORS: readonly ProviderDescriptor[] = CATALOG_ENTRY_LIST.flatMap(provider => { + if (!provider.createModelManagerOptions || provider.specialModelManager) { + return []; + } + return [ + { + providerId: provider.id, + defaultModel: provider.defaultModel, + createModelManagerOptions: provider.createModelManagerOptions, + allowUnauthenticated: provider.allowUnauthenticated, + dynamicModelsAuthoritative: provider.dynamicModelsAuthoritative, + catalogDiscovery: provider.catalogDiscovery + ? { ...provider.catalogDiscovery, envVars: provider.catalogDiscovery.envVars ?? provider.envVars ?? [] } + : undefined, + }, + ]; +}); + +/** Default model IDs for all known providers, derived from the catalog table. */ +export const DEFAULT_MODEL_PER_PROVIDER: Record = Object.fromEntries( + CATALOG_PROVIDERS.map(provider => [provider.id, provider.defaultModel] as [string, string]), +) as Record; + +export function getCatalogProviderEntry(id: string): ProviderCatalogEntry | undefined { + return CATALOG_PROVIDERS.find(provider => provider.id === id); +} diff --git a/packages/ai/src/provider-models/discovery-constants.ts b/packages/catalog/src/provider-models/discovery-constants.ts similarity index 100% rename from packages/ai/src/provider-models/discovery-constants.ts rename to packages/catalog/src/provider-models/discovery-constants.ts diff --git a/packages/ai/src/provider-models/google.ts b/packages/catalog/src/provider-models/google.ts similarity index 94% rename from packages/ai/src/provider-models/google.ts rename to packages/catalog/src/provider-models/google.ts index 00383b90f..459f12814 100644 --- a/packages/ai/src/provider-models/google.ts +++ b/packages/catalog/src/provider-models/google.ts @@ -1,7 +1,7 @@ +import { fetchAntigravityDiscoveryModels } from "../discovery/antigravity"; +import { fetchGeminiModels } from "../discovery/gemini"; import type { ModelManagerOptions } from "../model-manager"; import type { FetchImpl } from "../types"; -import { fetchAntigravityDiscoveryModels } from "../utils/discovery/antigravity"; -import { fetchGeminiModels } from "../utils/discovery/gemini"; export interface GoogleModelManagerConfig { apiKey?: string; diff --git a/packages/ai/src/provider-models/index.ts b/packages/catalog/src/provider-models/index.ts similarity index 79% rename from packages/ai/src/provider-models/index.ts rename to packages/catalog/src/provider-models/index.ts index 666feb4b5..9ff9da9e2 100644 --- a/packages/ai/src/provider-models/index.ts +++ b/packages/catalog/src/provider-models/index.ts @@ -1,3 +1,4 @@ +export * from "./descriptor-types"; export * from "./descriptors"; export * from "./google"; export * from "./ollama"; diff --git a/packages/ai/src/provider-models/ollama.ts b/packages/catalog/src/provider-models/ollama.ts similarity index 100% rename from packages/ai/src/provider-models/ollama.ts rename to packages/catalog/src/provider-models/ollama.ts diff --git a/packages/ai/src/provider-models/openai-compat.ts b/packages/catalog/src/provider-models/openai-compat.ts similarity index 99% rename from packages/ai/src/provider-models/openai-compat.ts rename to packages/catalog/src/provider-models/openai-compat.ts index a7ac02edb..7bb55a532 100644 --- a/packages/ai/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -1,15 +1,15 @@ -import { Effort } from "../effort"; -import type { ModelManagerOptions } from "../model-manager"; -import { getBundledModels } from "../models"; -import { getGitHubCopilotBaseUrl, OPENCODE_HEADERS, parseGitHubCopilotApiKey } from "../registry/oauth/github-copilot"; -import type { Api, FetchImpl, Model, Provider, ThinkingConfig } from "../types"; -import { isAnthropicOAuthToken, isRecord, toBoolean, toNumber, toPositiveNumber } from "../utils"; import { fetchOpenAICompatibleModels, type OpenAICompatibleModelMapperContext, type OpenAICompatibleModelRecord, -} from "../utils/discovery/openai-compatible"; -import { toFireworksPublicModelId } from "../utils/fireworks-model-id"; +} from "../discovery/openai-compatible"; +import { Effort } from "../effort"; +import { toFireworksPublicModelId } from "../fireworks-model-id"; +import type { ModelManagerOptions } from "../model-manager"; +import { getBundledModels } from "../models"; +import type { Api, FetchImpl, Model, Provider, ThinkingConfig } from "../types"; +import { isAnthropicOAuthToken, isRecord, toBoolean, toNumber, toPositiveNumber } from "../utils"; +import { getGitHubCopilotBaseUrl, OPENCODE_HEADERS, parseGitHubCopilotApiKey } from "../wire/github-copilot"; import { createBundledReferenceMap, createReferenceResolver } from "./bundled-references"; import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "./discovery-constants"; diff --git a/packages/ai/src/provider-models/special.ts b/packages/catalog/src/provider-models/special.ts similarity index 93% rename from packages/ai/src/provider-models/special.ts rename to packages/catalog/src/provider-models/special.ts index 283ea62f2..2c5f029a4 100644 --- a/packages/ai/src/provider-models/special.ts +++ b/packages/catalog/src/provider-models/special.ts @@ -1,6 +1,6 @@ import { once } from "@oh-my-pi/pi-utils"; +import { fetchCodexModels } from "../discovery/codex"; import type { ModelManagerOptions } from "../model-manager"; -import { fetchCodexModels } from "../utils/discovery/codex"; // --------------------------------------------------------------------------- // OpenAI Codex @@ -54,7 +54,7 @@ export function cursorModelManagerOptions(config: CursorModelManagerConfig = {}) }; } -const cursorDiscovery = once(() => import("../utils/discovery/cursor")); +const cursorDiscovery = once(() => import("../discovery/cursor")); // --------------------------------------------------------------------------- // Zai diff --git a/packages/catalog/src/types.ts b/packages/catalog/src/types.ts new file mode 100644 index 000000000..eedfeb2e9 --- /dev/null +++ b/packages/catalog/src/types.ts @@ -0,0 +1,330 @@ +import type { Effort } from "./effort"; + +export type { KnownProvider } from "./provider-models/descriptors"; + +export type KnownApi = + | "openai-completions" + | "openai-responses" + | "openai-codex-responses" + | "azure-openai-responses" + | "anthropic-messages" + | "bedrock-converse-stream" + | "google-generative-ai" + | "google-gemini-cli" + | "google-vertex" + | "ollama-chat" + | "cursor-agent"; +export type Api = KnownApi | (string & {}); + +/** Canonical thinking transport used by a model. */ +export type ThinkingControlMode = + | "effort" + | "budget" + | "google-level" + | "anthropic-adaptive" + | "anthropic-budget-effort"; + +/** Per-model thinking capabilities used to clamp and map user-facing effort levels. */ +export interface ThinkingConfig { + /** Least intensive supported user-facing effort level. */ + minLevel: Effort; + /** Most intensive supported user-facing effort level. */ + maxLevel: Effort; + /** + * Optional explicit list of supported levels. When present, takes precedence over + * the `minLevel`..`maxLevel` range — used to encode discrete sets with gaps + * (e.g. Gemini 3 Pro supports `low` and `high` but not `medium`). + */ + levels?: readonly Effort[]; + /** Optional default effort applied when this model is selected. Falls back to global default if absent. */ + defaultLevel?: Effort; + /** Provider-specific transport used to encode the selected effort. */ + mode: ThinkingControlMode; +} + +// `Provider` is any provider-id string; `KnownProvider` (re-exported above) enumerates +// the built-in model providers from the catalog descriptor table. +export type Provider = string; + +/** Token budgets for each thinking level (token-based providers only) */ +export type ThinkingBudgets = { [key in Effort]?: number }; + +/** + * `fetch`-compatible function. Accepts any callable matching the standard + * fetch signature; `preconnect` is optional because non-Bun runtimes (browsers, + * test mocks) won't expose it. + */ +export type FetchImpl = ((input: string | URL | Request, init?: RequestInit) => Promise) & { + preconnect?: typeof globalThis.fetch.preconnect; +}; + +export interface Usage { + /** Non-cached input tokens (matches the bucket the provider bills as new input). */ + input: number; + /** Total output tokens for the turn, including thinking, assistant text, and tool-call argument tokens. */ + output: number; + /** Tokens read from the prompt cache. */ + cacheRead: number; + /** Tokens written to the prompt cache (cache creation). */ + cacheWrite: number; + /** Sum of input + output + cacheRead + cacheWrite. */ + totalTokens: number; + /** Copilot premium-request counter, when applicable. */ + premiumRequests?: number; + /** + * Reasoning/thinking tokens included in `output`, when the provider reports them + * (OpenAI `output_tokens_details.reasoning_tokens`, Google `thoughtsTokenCount`). + * Always a subset of `output` — non-reasoning output is `output - reasoningTokens`. + * + * Providers that don't expose this leave it undefined rather than guessing; + * `undefined` means unknown, NOT zero. + */ + reasoningTokens?: number; + /** + * Cache-write TTL breakdown (Anthropic only). When set, the components sum to + * `cacheWrite`. Absent providers do not populate this. + */ + cttl?: { + ephemeral5m?: number; + ephemeral1h?: number; + }; + /** + * Server-side tool invocations made during this turn (Anthropic web_search / + * web_fetch, OpenAI built-in tools when reported). Counts requests, not tokens. + */ + server?: { + webSearch?: number; + webFetch?: number; + }; + cost: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + total: number; + }; +} + +/** + * Compatibility settings for openai-completions API. + * Use this to override URL-based auto-detection for custom providers. + */ +export interface OpenAICompat { + /** Whether the provider supports the `store` field. Default: auto-detected from URL. */ + supportsStore?: boolean; + /** Whether the provider supports the `developer` role (vs `system`). Default: auto-detected from URL. */ + supportsDeveloperRole?: boolean; + /** + * Whether the provider's chat-completions endpoint accepts multiple + * leading `system`/`developer` messages. When false, ordered system + * prompts are coalesced into a single message joined by `\n\n` so + * strict chat templates (e.g. Qwen-served via vLLM, MiniMax) accept + * the request. Default: detected per provider/baseUrl. Canonical + * OpenAI/Azure/OpenRouter/Cerebras/Together/Fireworks/Groq/DeepSeek/ + * Mistral/xAI/Z.ai/GitHub Copilot/Zenmux are treated as `true`; + * unknown or strict-template hosts default to `false`. Setting this + * to `true` preserves separate blocks, which is preferred for + * KV-cache reuse when the trailing prompt changes between calls. + */ + supportsMultipleSystemMessages?: boolean; + /** Whether the provider supports `reasoning_effort`. Default: auto-detected from URL. */ + supportsReasoningEffort?: boolean; + /** Optional mapping from pi-ai reasoning levels to provider/model-specific `reasoning_effort` values. */ + reasoningEffortMap?: Partial>; + /** Whether the provider supports `stream_options: { include_usage: true }` for token usage in streaming responses. Default: true. */ + supportsUsageInStreaming?: boolean; + /** Which field to use for max tokens. Default: auto-detected from URL. */ + maxTokensField?: "max_completion_tokens" | "max_tokens"; + /** Whether tool results require the `name` field. Default: auto-detected from URL. */ + requiresToolResultName?: boolean; + /** Whether a user message after tool results requires an assistant message in between. Default: auto-detected from URL. */ + requiresAssistantAfterToolResult?: boolean; + /** Whether thinking blocks must be converted to text blocks with delimiters. Default: auto-detected from URL. */ + requiresThinkingAsText?: boolean; + /** Whether tool call IDs must be normalized to Mistral format (exactly 9 alphanumeric chars). Default: auto-detected from URL. */ + requiresMistralToolIds?: boolean; + /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "zai" uses thinking: { type: "enabled" | "disabled" } (also used by Moonshot Kimi), "qwen" uses top-level enable_thinking, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */ + thinkingFormat?: "openai" | "openrouter" | "zai" | "qwen" | "qwen-chat-template"; + /** Optional `thinking.keep` value for Z.ai/Moonshot-style thinking params. Set false to suppress auto-detected keep. Default: auto-detected. */ + thinkingKeep?: "all" | false; + /** Which reasoning content field to emit on assistant messages. Default: auto-detected. */ + reasoningContentField?: "reasoning_content" | "reasoning" | "reasoning_text"; + /** Whether assistant tool-call messages must include reasoning content. Default: false. */ + requiresReasoningContentForToolCalls?: boolean; + /** Whether the provider accepts a synthetic placeholder (e.g. ".") for missing reasoning_content on tool-call turns. Default: true. Set to false for providers like DeepSeek that validate the exact reasoning_content value. */ + allowsSyntheticReasoningContentForToolCalls?: boolean; + /** Whether assistant tool-call messages must include non-empty content. Default: false. */ + requiresAssistantContentForToolCalls?: boolean; + /** Whether the provider supports the `tool_choice` parameter. Default: true. */ + supportsToolChoice?: boolean; + /** + * Drop reasoning fields (`reasoning_effort`, OpenRouter `reasoning`) for + * the request when `tool_choice` forces a tool call. Mirrors the Anthropic + * `disableThinkingIfToolChoiceForced` rule for backends like Kimi that + * 400 with `tool_choice 'specified' is incompatible with thinking + * enabled` whenever both are present. Default: auto-detected (Kimi). + */ + disableReasoningOnForcedToolChoice?: boolean; + /** + * Drop reasoning fields (`reasoning_effort`, OpenRouter `reasoning`) for + * any request that sends `tool_choice`. Use for providers/models that accept + * tools and `tool_choice`, but reject `tool_choice` while thinking is enabled. + * Default: auto-detected (DeepSeek reasoning models). + */ + disableReasoningOnToolChoice?: boolean; + /** OpenRouter-specific routing preferences. Only used when baseUrl points to OpenRouter. */ + openRouterRouting?: OpenRouterRouting; + /** Vercel AI Gateway routing preferences. Only used when baseUrl points to Vercel AI Gateway. */ + vercelGatewayRouting?: VercelGatewayRouting; + /** Extra fields to include in request body (e.g. gateway routing hints for OpenClaw-style proxies). */ + extraBody?: Record; + /** Whether chat-completions payloads should include provider-specific prompt-cache markers. */ + cacheControlFormat?: "anthropic" | undefined; + /** Whether the provider supports the `strict` field in tool definitions. Default: auto-detected per provider/baseUrl (conservative for unknown providers). */ + supportsStrictMode?: boolean; + /** Whether tool schemas must be sent either all strict or all non-strict. Undefined keeps the existing per-tool mixed behavior. */ + toolStrictMode?: "all_strict" | "none"; +} + +/** + * Compatibility settings for anthropic-messages API. + * Use this to disable features that strict-by-default Anthropic accepts but + * that proxy gateways (Vertex AI, AWS Bedrock-style fronts, etc.) reject. + */ +export interface AnthropicCompat { + /** + * Drop the top-level `strict: true` field on tool definitions. Vertex AI's + * Anthropic-compatible endpoint rejects unknown tool fields with + * `tools..custom.strict: Extra inputs are not permitted`. + */ + disableStrictTools?: boolean; + /** + * Map adaptive thinking (`thinking: { type: "adaptive" }`) to + * `{ type: "enabled", budget_tokens }`. Vertex AI rejects the `adaptive` + * tag with `Input tag 'adaptive' ... does not match any of the expected + * tags: 'disabled', 'enabled'`. + */ + disableAdaptiveThinking?: boolean; + /** Whether tools may include Anthropic's per-tool eager_input_streaming flag. Default: true. */ + supportsEagerToolInputStreaming?: boolean; + /** Whether long prompt-cache retention (`ttl: "1h"`) is supported. Default: true for canonical Anthropic API. */ + supportsLongCacheRetention?: boolean; + /** + * Whether mid-conversation `role: "system"` messages are accepted in the + * `messages` array (Claude Opus 4.8+ and Claude Fable/Mythos 5 on the + * first-party Claude API and Claude Platform on AWS). When unset, + * auto-detected from the model id and base URL. Not available on Bedrock, + * Vertex AI, or Microsoft Foundry. + */ + supportsMidConversationSystem?: boolean; + /** + * Whether the model accepts a forced `tool_choice` (`{ type: "any" }` or + * `{ type: "tool", name }`). Claude Fable/Mythos 5 reject forced tool use + * outright ("tool_choice forces tool use is not compatible with this model"); + * the request builder downgrades forced choices to `auto` when this is false. + * When unset, auto-detected from the model id. Default: true. + */ + supportsForcedToolChoice?: boolean; +} + +/** + * OpenRouter provider routing preferences. + * Controls which upstream providers OpenRouter routes requests to. + * @see https://openrouter.ai/docs/provider-routing + */ +export interface OpenRouterRouting { + /** List of provider slugs to exclusively use for this request (e.g., ["amazon-bedrock", "anthropic"]). */ + only?: string[]; + /** List of provider slugs to try in order (e.g., ["anthropic", "openai"]). */ + order?: string[]; +} + +/** + * Vercel AI Gateway routing preferences. + * Controls which upstream providers the gateway routes requests to. + * @see https://vercel.com/docs/ai-gateway/models-and-providers/provider-options + */ +export interface VercelGatewayRouting { + /** List of provider slugs to exclusively use for this request (e.g., ["bedrock", "anthropic"]). */ + only?: string[]; + /** List of provider slugs to try in order (e.g., ["anthropic", "openai"]). */ + order?: string[]; +} + +// Model interface for the unified model system +export interface Model { + id: string; + name: string; + api: TApi; + provider: Provider; + baseUrl: string; + reasoning: boolean; + input: ("text" | "image")[]; + cost: { + input: number; // $/million tokens + output: number; // $/million tokens + cacheRead: number; // $/million tokens + cacheWrite: number; // $/million tokens + }; + /** Premium Copilot requests charged per user-initiated request (defaults to 1). */ + premiumMultiplier?: number; + contextWindow: number; + maxTokens: number; + /** + * When `true`, providers MUST omit `max_output_tokens` (Responses) / + * `max_tokens` / `max_completion_tokens` (Completions) from the outbound + * request and let the upstream API decide the per-response cap. `maxTokens` + * is still used locally for budgeting (compaction, context promotion); only + * the wire field is suppressed. + * + * Use this for proxies (notably Ollama) that forward to a backend whose true + * output limit OMP cannot discover — sending the wrong value triggers 400s + * from the upstream provider. + */ + omitMaxOutputTokens?: boolean; + headers?: Record; + /** + * Streaming transport override. When `"pi-native"`, `streamSimple` routes + * the request to the model's `baseUrl` via the auth-gateway's + * `POST /v1/pi/stream` endpoint instead of dispatching the per-API + * provider client. The `baseUrl` must point at an `omp auth-gateway` + * (or compatible) host; `headers.Authorization` (or `apiKey` resolved by + * the registry) carries the gateway bearer. + * + * Used by containerized omp installs (e.g. robomp slots) to route every + * LLM call through a sidecar gateway that holds the real provider + * credentials. The model's other metadata (pricing, context window, + * thinking config, …) still resolves locally; only the streaming + * dispatch is redirected. + */ + transport?: "pi-native"; + /** Hint that websocket transport should be preferred when supported by the provider implementation. */ + preferWebsockets?: boolean; + /** Preferred model to switch to when context promotion is triggered (model id or provider/id). */ + contextPromotionTarget?: string; + /** Provider-assigned priority value (lower = higher priority). */ + priority?: number; + /** Canonical thinking capability metadata for this model. */ + thinking?: ThinkingConfig; + /** Compatibility overrides per API. If not set, auto-detected from baseUrl. */ + compat?: TApi extends "openai-completions" | "openai-responses" + ? OpenAICompat + : TApi extends "anthropic-messages" + ? AnthropicCompat + : never; + /** + * Which shape to use when exposing the Codex `apply_patch` tool to this model. + * Generated catalog policy sets `"freeform"` for first-party GPT-5 Responses + * models that support OpenAI custom tools with a Lark grammar. The freeform + * variant sends a raw patch string with no JSON envelope. + * - `"function"` or undefined: JSON function-tool with `{input: string}` (spec §1.2). + */ + applyPatchToolType?: "freeform" | "function"; + /** + * Force OAuth-style request shaping for providers whose API key prefix doesn't + * match an OAuth token (e.g. routing Anthropic traffic through a proxy that + * expects Claude Code framing). When true, the streaming layer sets + * `options.isOAuth = true` for the underlying provider call. + */ + isOAuth?: boolean; +} diff --git a/packages/catalog/src/utils.ts b/packages/catalog/src/utils.ts new file mode 100644 index 000000000..16fceb6f0 --- /dev/null +++ b/packages/catalog/src/utils.ts @@ -0,0 +1,27 @@ +export { isRecord } from "@oh-my-pi/pi-utils"; + +export function toNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return undefined; +} + +export function toPositiveNumber(value: unknown, fallback: number): number { + const parsed = toNumber(value); + return parsed !== undefined && parsed > 0 ? parsed : fallback; +} + +export function toBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +export function isAnthropicOAuthToken(key: string): boolean { + return key.includes("sk-ant-oat"); +} diff --git a/packages/ai/src/providers/openai-codex/constants.ts b/packages/catalog/src/wire/codex.ts similarity index 100% rename from packages/ai/src/providers/openai-codex/constants.ts rename to packages/catalog/src/wire/codex.ts diff --git a/packages/ai/src/providers/google-gemini-headers.ts b/packages/catalog/src/wire/gemini-headers.ts similarity index 100% rename from packages/ai/src/providers/google-gemini-headers.ts rename to packages/catalog/src/wire/gemini-headers.ts diff --git a/packages/catalog/src/wire/github-copilot.ts b/packages/catalog/src/wire/github-copilot.ts new file mode 100644 index 000000000..610e0eb2b --- /dev/null +++ b/packages/catalog/src/wire/github-copilot.ts @@ -0,0 +1,72 @@ +/** + * GitHub Copilot wire metadata: API-key envelope parsing and endpoint + * derivation shared by catalog discovery and the pi-ai OAuth flow. The device + * login / token refresh flow lives in `@oh-my-pi/pi-ai`'s registry. + */ + +export const COPILOT_USER_AGENT = "opencode/1.3.15" as const; + +export const OPENCODE_HEADERS = { + "User-Agent": COPILOT_USER_AGENT, +} as const; + +type GitHubCopilotApiKeyPayload = { + token?: unknown; + enterpriseUrl?: unknown; +}; + +export type ParsedGitHubCopilotApiKey = { + accessToken: string; + enterpriseUrl?: string; +}; + +const PUBLIC_GITHUB_HOSTS = new Set(["api.github.com", "github.com", "www.github.com"]); + +export function isPublicGitHubHost(host: string): boolean { + return PUBLIC_GITHUB_HOSTS.has(host.trim().toLowerCase()); +} + +export function normalizeGitHubCopilotEnterpriseDomain(input: string | undefined): string | undefined { + const trimmed = input?.trim(); + if (!trimmed) return undefined; + const normalized = normalizeDomain(trimmed) ?? trimmed.toLowerCase(); + if (!normalized || isPublicGitHubHost(normalized)) return undefined; + return normalized; +} + +export function parseGitHubCopilotApiKey(apiKeyRaw: string): ParsedGitHubCopilotApiKey { + try { + const parsed = JSON.parse(apiKeyRaw) as GitHubCopilotApiKeyPayload; + if (typeof parsed.token === "string") { + return { + accessToken: parsed.token, + enterpriseUrl: + typeof parsed.enterpriseUrl === "string" + ? normalizeGitHubCopilotEnterpriseDomain(parsed.enterpriseUrl) + : undefined, + }; + } + } catch {} + + return { accessToken: apiKeyRaw }; +} + +export function normalizeDomain(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + try { + const url = trimmed.includes("://") ? new URL(trimmed) : new URL(`https://${trimmed}`); + return url.hostname; + } catch { + return null; + } +} + +export function getGitHubCopilotBaseUrl(enterpriseDomain?: string): string { + const normalizedEnterpriseDomain = normalizeGitHubCopilotEnterpriseDomain(enterpriseDomain); + if (!normalizedEnterpriseDomain) return "https://api.githubcopilot.com"; + const host = normalizedEnterpriseDomain.startsWith("copilot-api.") + ? normalizedEnterpriseDomain + : `copilot-api.${normalizedEnterpriseDomain}`; + return `https://${host}`; +} diff --git a/packages/catalog/test/descriptors.test.ts b/packages/catalog/test/descriptors.test.ts new file mode 100644 index 000000000..12b9c7636 --- /dev/null +++ b/packages/catalog/test/descriptors.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; +import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-catalog/provider-models"; + +describe("catalog provider descriptors", () => { + test("descriptors cover standard model providers, excluding special-managed ones", () => { + const zenmux = PROVIDER_DESCRIPTORS.find(descriptor => descriptor.providerId === "zenmux"); + expect(zenmux).toBeDefined(); + expect(zenmux?.defaultModel).toBe("anthropic/claude-opus-4.6"); + // The descriptor factory carries the provider identity through. + expect(zenmux?.createModelManagerOptions({ apiKey: "k" }).providerId).toBe("zenmux"); + + // openai-codex is special-managed (bespoke runtime factory) → excluded from descriptors, + // but still a known model provider with a default. + expect(PROVIDER_DESCRIPTORS.some(descriptor => descriptor.providerId === "openai-codex")).toBe(false); + expect(DEFAULT_MODEL_PER_PROVIDER["openai-codex"]).toBe("gpt-5.4"); + // Login-only tools have no default model. + expect(DEFAULT_MODEL_PER_PROVIDER).not.toHaveProperty("kagi"); + }); + + test("every descriptor has a default model and a factory that preserves provider identity", () => { + for (const descriptor of PROVIDER_DESCRIPTORS) { + expect(descriptor.defaultModel).toBeTruthy(); + expect(typeof descriptor.createModelManagerOptions).toBe("function"); + expect(descriptor.createModelManagerOptions({ apiKey: "k" }).providerId).toBe(descriptor.providerId); + } + }); +}); diff --git a/packages/ai/test/github-copilot-model-limits.test.ts b/packages/catalog/test/github-copilot-model-limits.test.ts similarity index 96% rename from packages/ai/test/github-copilot-model-limits.test.ts rename to packages/catalog/test/github-copilot-model-limits.test.ts index 8e45cbd34..e0df015e8 100644 --- a/packages/ai/test/github-copilot-model-limits.test.ts +++ b/packages/catalog/test/github-copilot-model-limits.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { Effort } from "@oh-my-pi/pi-ai/effort"; -import { createModelManager } from "@oh-my-pi/pi-ai/model-manager"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; -import { githubCopilotModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; +import { Effort } from "@oh-my-pi/pi-catalog/effort"; +import { createModelManager } from "@oh-my-pi/pi-catalog/model-manager"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; +import { githubCopilotModelManagerOptions } from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; function getHeaderValue(headers: unknown, key: string): string | undefined { if (!headers) return undefined; diff --git a/packages/ai/test/github-copilot-oauth.test.ts b/packages/catalog/test/github-copilot-wire.test.ts similarity index 95% rename from packages/ai/test/github-copilot-oauth.test.ts rename to packages/catalog/test/github-copilot-wire.test.ts index 36a44ee84..232505dac 100644 --- a/packages/ai/test/github-copilot-oauth.test.ts +++ b/packages/catalog/test/github-copilot-wire.test.ts @@ -3,7 +3,7 @@ import { getGitHubCopilotBaseUrl, normalizeGitHubCopilotEnterpriseDomain, parseGitHubCopilotApiKey, -} from "@oh-my-pi/pi-ai/registry/oauth/github-copilot"; +} from "@oh-my-pi/pi-catalog/wire/github-copilot"; describe("GitHub Copilot OAuth helpers", () => { it("treats github.com as the public Copilot host", () => { diff --git a/packages/ai/test/google-vertex-discovery.test.ts b/packages/catalog/test/google-vertex-discovery.test.ts similarity index 91% rename from packages/ai/test/google-vertex-discovery.test.ts rename to packages/catalog/test/google-vertex-discovery.test.ts index f7c2780b2..fac2331f2 100644 --- a/packages/ai/test/google-vertex-discovery.test.ts +++ b/packages/catalog/test/google-vertex-discovery.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "bun:test"; -import { resolveProviderModels } from "@oh-my-pi/pi-ai/model-manager"; -import { googleVertexModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/google"; -import { MODELS_DEV_PROVIDER_DESCRIPTORS, mapModelsDevToModels } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; +import { resolveProviderModels } from "@oh-my-pi/pi-catalog/model-manager"; +import { googleVertexModelManagerOptions } from "@oh-my-pi/pi-catalog/provider-models/google"; +import { + MODELS_DEV_PROVIDER_DESCRIPTORS, + mapModelsDevToModels, +} from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; const googleVertexModelsDevPayload = { "google-vertex": { diff --git a/packages/ai/test/issue-1617-repro.test.ts b/packages/catalog/test/issue-1617-repro.test.ts similarity index 97% rename from packages/ai/test/issue-1617-repro.test.ts rename to packages/catalog/test/issue-1617-repro.test.ts index 9efbf9c3d..101a2f93c 100644 --- a/packages/ai/test/issue-1617-repro.test.ts +++ b/packages/catalog/test/issue-1617-repro.test.ts @@ -19,8 +19,8 @@ import { type ModelsDevModel, opencodeGoModelManagerOptions, opencodeZenModelManagerOptions, -} from "@oh-my-pi/pi-ai/provider-models/openai-compat"; -import type { FetchImpl } from "@oh-my-pi/pi-ai/types"; +} from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; +import type { FetchImpl } from "@oh-my-pi/pi-catalog/types"; const OPENCODE_ZEN_BASE = "https://opencode.ai/zen/v1"; const OPENCODE_GO_BASE = "https://opencode.ai/zen/go/v1"; diff --git a/packages/ai/test/issue-1846-repro.test.ts b/packages/catalog/test/issue-1846-repro.test.ts similarity index 94% rename from packages/ai/test/issue-1846-repro.test.ts rename to packages/catalog/test/issue-1846-repro.test.ts index e81365d54..b6060e2e3 100644 --- a/packages/ai/test/issue-1846-repro.test.ts +++ b/packages/catalog/test/issue-1846-repro.test.ts @@ -2,10 +2,11 @@ import { Database } from "bun:sqlite"; import { afterEach, describe, expect, it, vi } from "bun:test"; import { AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; -import { xiaomiModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; import { convertMessages, detectCompat } from "@oh-my-pi/pi-ai/providers/openai-completions"; import { getOAuthProviders } from "@oh-my-pi/pi-ai/registry/oauth"; -import type { AssistantMessage, FetchImpl, Model, ThinkingContent, ToolCall } from "@oh-my-pi/pi-ai/types"; +import type { AssistantMessage, ThinkingContent, ToolCall } from "@oh-my-pi/pi-ai/types"; +import { xiaomiModelManagerOptions } from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; +import type { FetchImpl, Model } from "@oh-my-pi/pi-catalog/types"; const TP_KEY = "tp-ci1p8t1w4e1sbxgyc8v65tnrjbzro287igmvyf25van9mt76"; const SGP_BASE_URL = "https://token-plan-sgp.xiaomimimo.com/v1"; diff --git a/packages/ai/test/issue-1849-repro.test.ts b/packages/catalog/test/issue-1849-repro.test.ts similarity index 95% rename from packages/ai/test/issue-1849-repro.test.ts rename to packages/catalog/test/issue-1849-repro.test.ts index 63a912fd7..110e02cd6 100644 --- a/packages/ai/test/issue-1849-repro.test.ts +++ b/packages/catalog/test/issue-1849-repro.test.ts @@ -13,12 +13,12 @@ * generator regenerates. */ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { clampFireworksKimiMaxTokens, FIREWORKS_KIMI_MAX_TOKENS, isFireworksKimiK2ModelId, -} from "@oh-my-pi/pi-ai/provider-models/openai-compat"; +} from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; describe("Fireworks Kimi K2 maxTokens cap (#1849)", () => { it("recognizes Kimi K2.x public and wire ids", () => { diff --git a/packages/ai/test/issue-2105-repro.test.ts b/packages/catalog/test/issue-2105-repro.test.ts similarity index 93% rename from packages/ai/test/issue-2105-repro.test.ts rename to packages/catalog/test/issue-2105-repro.test.ts index 44354c056..c8e91e75e 100644 --- a/packages/ai/test/issue-2105-repro.test.ts +++ b/packages/catalog/test/issue-2105-repro.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "../src/provider-models/descriptors"; -import { aimlApiModelManagerOptions, isLikelyAimlApiChatModelId } from "../src/provider-models/openai-compat"; -import { getEnvApiKey } from "../src/stream"; +import { getEnvApiKey } from "@oh-my-pi/pi-ai/stream"; +import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-catalog/provider-models/descriptors"; +import { + aimlApiModelManagerOptions, + isLikelyAimlApiChatModelId, +} from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; describe("AIML API built-in provider (issue #2105)", () => { test("registers built-in runtime descriptor with AIMLAPI_API_KEY discovery", () => { diff --git a/packages/ai/test/issue-2113-repro.test.ts b/packages/catalog/test/issue-2113-repro.test.ts similarity index 94% rename from packages/ai/test/issue-2113-repro.test.ts rename to packages/catalog/test/issue-2113-repro.test.ts index 81ed3bb2f..400e7f7b0 100644 --- a/packages/ai/test/issue-2113-repro.test.ts +++ b/packages/catalog/test/issue-2113-repro.test.ts @@ -17,11 +17,12 @@ * moonshot discovery mapper and stamps default thinking metadata. */ import { describe, expect, it } from "bun:test"; -import { Effort } from "@oh-my-pi/pi-ai/effort"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; -import { moonshotModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; -import type { AssistantMessage, Context, Model } from "@oh-my-pi/pi-ai/types"; +import type { AssistantMessage, Context } from "@oh-my-pi/pi-ai/types"; +import { Effort } from "@oh-my-pi/pi-catalog/effort"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; +import { moonshotModelManagerOptions } from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; +import type { Model } from "@oh-my-pi/pi-catalog/types"; function moonshotKimiModel(id: string, reasoning: boolean): Model<"openai-completions"> { return { diff --git a/packages/ai/test/issue-772-repro.test.ts b/packages/catalog/test/issue-772-repro.test.ts similarity index 93% rename from packages/ai/test/issue-772-repro.test.ts rename to packages/catalog/test/issue-772-repro.test.ts index 6d3bece66..217725f1c 100644 --- a/packages/ai/test/issue-772-repro.test.ts +++ b/packages/catalog/test/issue-772-repro.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { xiaomiModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; import { loginXiaomi } from "@oh-my-pi/pi-ai/registry/oauth/xiaomi"; -import type { FetchImpl } from "@oh-my-pi/pi-ai/types"; +import { xiaomiModelManagerOptions } from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; +import type { FetchImpl } from "@oh-my-pi/pi-catalog/types"; const TOKEN_PLAN_SGP_HOST = "token-plan-sgp.xiaomimimo.com"; const STANDARD_HOST = "api.xiaomimimo.com"; diff --git a/packages/ai/test/issue-830-repro.test.ts b/packages/catalog/test/issue-830-repro.test.ts similarity index 92% rename from packages/ai/test/issue-830-repro.test.ts rename to packages/catalog/test/issue-830-repro.test.ts index 4a6e53cb1..72c86e01a 100644 --- a/packages/ai/test/issue-830-repro.test.ts +++ b/packages/catalog/test/issue-830-repro.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-ai/provider-models/descriptors"; -import { MODELS_DEV_PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; import { getOAuthProviders } from "@oh-my-pi/pi-ai/registry/oauth"; import { getEnvApiKey } from "@oh-my-pi/pi-ai/stream"; -import type { OpenAICompat } from "@oh-my-pi/pi-ai/types"; +import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-catalog/provider-models/descriptors"; +import { MODELS_DEV_PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; +import type { OpenAICompat } from "@oh-my-pi/pi-catalog/types"; describe("deepseek built-in provider (issue #830)", () => { test("registers built-in runtime descriptor with DEEPSEEK_API_KEY env discovery", () => { diff --git a/packages/ai/test/issue-847-repro.test.ts b/packages/catalog/test/issue-847-repro.test.ts similarity index 96% rename from packages/ai/test/issue-847-repro.test.ts rename to packages/catalog/test/issue-847-repro.test.ts index c6137bdf5..828d11b8b 100644 --- a/packages/ai/test/issue-847-repro.test.ts +++ b/packages/catalog/test/issue-847-repro.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test, vi } from "bun:test"; -import { ollamaModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; -import type { FetchImpl } from "@oh-my-pi/pi-ai/types"; +import { ollamaModelManagerOptions } from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; +import type { FetchImpl } from "@oh-my-pi/pi-catalog/types"; afterEach(() => { vi.restoreAllMocks(); diff --git a/packages/ai/test/issue-887-repro.test.ts b/packages/catalog/test/issue-887-repro.test.ts similarity index 97% rename from packages/ai/test/issue-887-repro.test.ts rename to packages/catalog/test/issue-887-repro.test.ts index 1f5b92b45..09f445c0e 100644 --- a/packages/ai/test/issue-887-repro.test.ts +++ b/packages/catalog/test/issue-887-repro.test.ts @@ -13,7 +13,7 @@ import { MODELS_DEV_PROVIDER_DESCRIPTORS, type ModelsDevModel, opencodeGoModelManagerOptions, -} from "@oh-my-pi/pi-ai/provider-models/openai-compat"; +} from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; const OPENCODE_GO_BASE = "https://opencode.ai/zen/go/v1"; diff --git a/packages/coding-agent/test/model-id-affixes.test.ts b/packages/catalog/test/model-id-affixes.test.ts similarity index 97% rename from packages/coding-agent/test/model-id-affixes.test.ts rename to packages/catalog/test/model-id-affixes.test.ts index cad39ce7f..048fafd4a 100644 --- a/packages/coding-agent/test/model-id-affixes.test.ts +++ b/packages/catalog/test/model-id-affixes.test.ts @@ -4,7 +4,7 @@ import { getLongestModelLikeIdSegment, getModelLikeIdSegments, stripBracketedModelIdAffixes, -} from "@oh-my-pi/pi-coding-agent/config/model-id-affixes"; +} from "@oh-my-pi/pi-catalog/identity/id"; describe("getModelLikeIdSegments", () => { test("keeps only family-prefixed segments that carry a digit, deduped", () => { diff --git a/packages/coding-agent/test/model-provider-priority.test.ts b/packages/catalog/test/model-provider-priority.test.ts similarity index 86% rename from packages/coding-agent/test/model-provider-priority.test.ts rename to packages/catalog/test/model-provider-priority.test.ts index e026b28a9..4cdaa9810 100644 --- a/packages/coding-agent/test/model-provider-priority.test.ts +++ b/packages/catalog/test/model-provider-priority.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { buildModelProviderPriorityRank } from "../src/config/model-provider-priority"; +import { buildModelProviderPriorityRank } from "@oh-my-pi/pi-catalog/identity/priority"; describe("model provider priority", () => { test("ranks AIML API with hosted aggregators", () => { diff --git a/packages/ai/test/model-thinking.test.ts b/packages/catalog/test/model-thinking.test.ts similarity index 98% rename from packages/ai/test/model-thinking.test.ts rename to packages/catalog/test/model-thinking.test.ts index e55934adb..e359bc86b 100644 --- a/packages/ai/test/model-thinking.test.ts +++ b/packages/catalog/test/model-thinking.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { Effort } from "@oh-my-pi/pi-ai/effort"; +import { Effort } from "@oh-my-pi/pi-catalog/effort"; import { applyGeneratedModelPolicies, clampThinkingLevelForModel, @@ -8,8 +8,8 @@ import { mapEffortToAnthropicAdaptiveEffort, mapEffortToGoogleThinkingLevel, requireSupportedEffort, -} from "@oh-my-pi/pi-ai/model-thinking"; -import type { Api, Model, Provider } from "@oh-my-pi/pi-ai/types"; +} from "@oh-my-pi/pi-catalog/model-thinking"; +import type { Api, Model, Provider } from "@oh-my-pi/pi-catalog/types"; function createModel(overrides: { id: string; diff --git a/packages/ai/test/nanogpt-model-limits.test.ts b/packages/catalog/test/nanogpt-model-limits.test.ts similarity index 92% rename from packages/ai/test/nanogpt-model-limits.test.ts rename to packages/catalog/test/nanogpt-model-limits.test.ts index d8266d284..68e6a60a4 100644 --- a/packages/ai/test/nanogpt-model-limits.test.ts +++ b/packages/catalog/test/nanogpt-model-limits.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "bun:test"; -import { Effort } from "@oh-my-pi/pi-ai/effort"; -import { nanoGptModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; +import { Effort } from "@oh-my-pi/pi-catalog/effort"; +import { nanoGptModelManagerOptions } from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; async function discoverNanoGptModels( payload: unknown, diff --git a/packages/ai/test/ollama-cloud-provider.test.ts b/packages/catalog/test/ollama-cloud-provider.test.ts similarity index 98% rename from packages/ai/test/ollama-cloud-provider.test.ts rename to packages/catalog/test/ollama-cloud-provider.test.ts index 69be295f9..b7c48846b 100644 --- a/packages/ai/test/ollama-cloud-provider.test.ts +++ b/packages/catalog/test/ollama-cloud-provider.test.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, test, vi } from "bun:test"; -import { ollamaCloudModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/ollama"; import { completeSimple, getEnvApiKey, stream, streamSimple } from "@oh-my-pi/pi-ai/stream"; -import type { Context, FetchImpl, Model, Tool } from "@oh-my-pi/pi-ai/types"; +import type { Context, Tool } from "@oh-my-pi/pi-ai/types"; +import { ollamaCloudModelManagerOptions } from "@oh-my-pi/pi-catalog/provider-models/ollama"; +import type { FetchImpl, Model } from "@oh-my-pi/pi-catalog/types"; const originalApiKey = Bun.env.OLLAMA_CLOUD_API_KEY; diff --git a/packages/ai/test/ollama-provider.test.ts b/packages/catalog/test/ollama-provider.test.ts similarity index 94% rename from packages/ai/test/ollama-provider.test.ts rename to packages/catalog/test/ollama-provider.test.ts index 746fcb559..56cff3966 100644 --- a/packages/ai/test/ollama-provider.test.ts +++ b/packages/catalog/test/ollama-provider.test.ts @@ -1,8 +1,9 @@ import { describe, expect, test, vi } from "bun:test"; -import { Effort } from "@oh-my-pi/pi-ai/effort"; -import { ollamaModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; import { streamOllama } from "@oh-my-pi/pi-ai/providers/ollama"; -import type { Context, FetchImpl, Model, Tool } from "@oh-my-pi/pi-ai/types"; +import type { Context, Tool } from "@oh-my-pi/pi-ai/types"; +import { Effort } from "@oh-my-pi/pi-catalog/effort"; +import { ollamaModelManagerOptions } from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; +import type { FetchImpl, Model } from "@oh-my-pi/pi-catalog/types"; interface OllamaRequestBody { tools?: Array<{ function: { name: string } }>; diff --git a/packages/ai/test/wafer.test.ts b/packages/catalog/test/wafer.test.ts similarity index 97% rename from packages/ai/test/wafer.test.ts rename to packages/catalog/test/wafer.test.ts index 9abfad5b7..6ac466e48 100644 --- a/packages/ai/test/wafer.test.ts +++ b/packages/catalog/test/wafer.test.ts @@ -11,14 +11,15 @@ * the case-sensitive id pass-through against the wire. */ import { describe, expect, it } from "bun:test"; -import { createModelManager } from "@oh-my-pi/pi-ai/model-manager"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; +import type { Context } from "@oh-my-pi/pi-ai/types"; +import { createModelManager } from "@oh-my-pi/pi-catalog/model-manager"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { waferPassModelManagerOptions, waferServerlessModelManagerOptions, -} from "@oh-my-pi/pi-ai/provider-models/openai-compat"; -import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; -import type { Context, FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; +} from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; +import type { FetchImpl, Model } from "@oh-my-pi/pi-catalog/types"; function sseResponse(events: unknown[]): Response { const payload = `${events.map(e => `data: ${typeof e === "string" ? e : JSON.stringify(e)}`).join("\n\n")}\n\n`; diff --git a/packages/ai/test/xai-oauth-bundle.test.ts b/packages/catalog/test/xai-oauth-bundle.test.ts similarity index 83% rename from packages/ai/test/xai-oauth-bundle.test.ts rename to packages/catalog/test/xai-oauth-bundle.test.ts index c688ca1fc..e09d7c816 100644 --- a/packages/ai/test/xai-oauth-bundle.test.ts +++ b/packages/catalog/test/xai-oauth-bundle.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { buildXaiOAuthStaticSeed } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; -import type { Model } from "@oh-my-pi/pi-ai/types"; -import MODELS_JSON from "../src/models.json" with { type: "json" }; +import MODELS_JSON from "@oh-my-pi/pi-catalog/models.json" with { type: "json" }; +import { buildXaiOAuthStaticSeed } from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; +import type { Model } from "@oh-my-pi/pi-catalog/types"; // Pins the invariant: bundled `models.json` carries every entry the runtime // curated catalog (XAI_OAUTH_CURATED_MODELS, surfaced via @@ -13,7 +13,8 @@ import MODELS_JSON from "../src/models.json" with { type: "json" }; // // Failure here means: run `bun run generate-models` and commit the diff. describe("xai-oauth bundled catalog (regression)", () => { - const bundled = (MODELS_JSON as Record>>)["xai-oauth"] ?? {}; + const bundled = + (MODELS_JSON as unknown as Record>>)["xai-oauth"] ?? {}; const seed = buildXaiOAuthStaticSeed(); it("bundles every curated id", () => { diff --git a/packages/ai/test/zenmux-provider.test.ts b/packages/catalog/test/zenmux-provider.test.ts similarity index 94% rename from packages/ai/test/zenmux-provider.test.ts rename to packages/catalog/test/zenmux-provider.test.ts index 73b38d99f..306671195 100644 --- a/packages/ai/test/zenmux-provider.test.ts +++ b/packages/catalog/test/zenmux-provider.test.ts @@ -1,9 +1,9 @@ import { afterEach, describe, expect, test, vi } from "bun:test"; -import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-ai/provider-models/descriptors"; -import { zenmuxModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; import { getOAuthProviders } from "@oh-my-pi/pi-ai/registry/oauth"; import { getEnvApiKey } from "@oh-my-pi/pi-ai/stream"; -import type { FetchImpl } from "@oh-my-pi/pi-ai/types"; +import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-catalog/provider-models/descriptors"; +import { zenmuxModelManagerOptions } from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; +import type { FetchImpl } from "@oh-my-pi/pi-catalog/types"; const originalZenMuxApiKey = Bun.env.ZENMUX_API_KEY; diff --git a/packages/ai/test/zhipu-compat.test.ts b/packages/catalog/test/zhipu-compat.test.ts similarity index 96% rename from packages/ai/test/zhipu-compat.test.ts rename to packages/catalog/test/zhipu-compat.test.ts index 598466372..343ccf265 100644 --- a/packages/ai/test/zhipu-compat.test.ts +++ b/packages/catalog/test/zhipu-compat.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { zhipuCodingPlanModelManagerOptions } from "@oh-my-pi/pi-ai/provider-models/openai-compat"; -import { detectOpenAICompat, resolveOpenAICompat } from "@oh-my-pi/pi-ai/providers/openai-completions-compat"; -import type { FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; +import { detectOpenAICompat, resolveOpenAICompat } from "@oh-my-pi/pi-catalog/compat/openai"; +import { zhipuCodingPlanModelManagerOptions } from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; +import type { FetchImpl, Model } from "@oh-my-pi/pi-catalog/types"; /** * Resolver-branch coverage for the `isZhipu` path added by the diff --git a/packages/catalog/tsconfig.json b/packages/catalog/tsconfig.json new file mode 100644 index 000000000..9cc6f4593 --- /dev/null +++ b/packages/catalog/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.workspace.json", + "include": ["src", "test", "scripts"] +} diff --git a/packages/catalog/tsconfig.publish.json b/packages/catalog/tsconfig.publish.json new file mode 100644 index 000000000..5e5542fc0 --- /dev/null +++ b/packages/catalog/tsconfig.publish.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "rootDir": "src", + "outDir": "dist/types" + }, + "include": ["src"], + "exclude": ["dist", "node_modules", "test"] +} diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3e0865299..b877c3e80 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,10 +12,15 @@ ### Changed +- Centralized model-identity logic in the new `@oh-my-pi/pi-catalog` package: `config/model-equivalence.ts`, `config/model-id-affixes.ts`, and `config/model-provider-priority.ts` were removed in favor of `@oh-my-pi/pi-catalog/identity`, and the registry's proxy-reference lookup now shares the catalog's single lazily-built bundled-model walk (`@oh-my-pi/pi-catalog/identity` bundled accessors) with the canonical-equivalence index instead of walking the ~12K bundled models twice into duplicate maps +- Split the configured/implicit provider discovery protocols (Ollama, llama.cpp, LM Studio, openai-models-list, proxy) out of `config/model-registry.ts` into `config/model-discovery.ts`; the registry keeps orchestration (caching, status tracking, merging) while the protocol clients take an injected fetch/auth context +- Catalog *values* (bundled models, `modelsAreEqual`, `clampThinkingLevelForModel`, `getSupportedEfforts`, `DEFAULT_MODEL_PER_PROVIDER`, Gemini/Antigravity wire headers) are now imported from `@oh-my-pi/pi-catalog/` instead of the `@oh-my-pi/pi-ai` barrel, which no longer re-exports them; the resolver's `defaultModelPerProvider` alias was removed and its duplicated default-model fallback / scoped-model dedupe blocks were factored into `pickDefaultAvailableModel` and a shared `addScopedModel` helper - Cached custom model alias maps and built them lazily on first custom model reference lookup, avoiding unnecessary startup model-registry initialization - Cached resolved auth broker configuration and snapshot reads for the process lifetime so repeated startup paths reuse the same `OMP_AUTH_BROKER_*` resolution instead of re-running config/token discovery - Reused task-agent discovery results for repeated `TaskTool.create` calls in the same working directory to avoid repeated plugin scans during subagent startup +- SSH tool creation now formats host descriptions from synchronous host-info cache reads (memory hit or cached JSON) instead of per-host async reads — hosts without cached info render the existing placeholder; warm-cache descriptions are byte-identical - Deferred heavy dependencies off the startup import graph to first feature use: `linkedom` (web fetch feed parsing and scrapers), `puppeteer-core`/`@puppeteer/browsers` (browser launch), `@mozilla/readability` (page extraction), `@xterm/headless` (interactive bash PTY), `@babel/parser` (JS eval import rewriting), and the mnemopi memory engine (backend/state construction) +- Renamed the `PI_TIMING` startup phase `discoverModels` to `discoverAuthStorage` — the timer only ever wrapped auth storage discovery - Worker threads (stats sync, browser tab, JS eval) and the tiny-model subprocess now re-enter the CLI entrypoint with hidden argv selectors (`__omp_*`, `--tiny-worker`) via the declared worker-host entry (`workerHostEntry()`), collapsing the per-distribution spawn branches; outside a CLI host (bun test, SDK embedding) spawn sites fall back to loading the worker module directly, and both binary build scripts dropped their per-worker `--compile` entrypoint lists - The CLI entry no longer top-level-awaits `runCli` — the floating call reports rejections to stderr and exits 1, keeping the entry module CJS-lowerable and the bundle parse-friendly - Tightened the system prompt and tool prompts: deduped restated warnings (bash "catch yourself" list, search/find shell-fallback recaps, read instruction/critical overlap, the AST metavariable primer duplicated across both ast tool descriptions), factored the repeated repo-default clause in the `gh` search ops, dropped a dead `rsed` reference and an internal `tool-timeouts.ts` pointer, and pruned internal mechanism the agent can't act on (screenshot temp-file/downscaling pipeline, browser spawn lifecycle, `gh` "replaces former op" history and run-watch grace period, output-minimizer heuristics, BM25 ranking name, `task.maxConcurrency` pointer) @@ -32,6 +37,8 @@ - Task progress snapshots shallow-copy per-agent progress instead of `structuredClone`-ing nested tool payloads (up to 500KB) on every progress event; streaming assistant-message reveal caches per-block grapheme counts and skips the markdown render LRU for in-flight partials, eliminating 2-3 full Intl.Segmenter walks per 33ms tick and tens of MB of retained stale partial snapshots on long replies. - Python eval cells: the availability probe is cached per cwd (was two interpreter spawns per cell even with a hot kernel), and stdout frames coalesce per write instead of one locked+flushed JSON frame each. - Multi-entry edits now stop at the first failing entry and report exactly which entries were applied and which were not — continuing after a failure applied later entries authored against line numbers that assumed the failed entry succeeded, and a retry of the whole batch then double-applied the survivors. +- Decomposed `config/model-registry.ts` further: model roles (`MODEL_ROLES`, `getRoleInfo`, `getKnownRoleIds`) moved to `config/model-roles.ts`, the `models.json` config handle and provider validation moved to `config/models-config.ts`, the two provider+id merge scaffolds collapsed into one `mergeByModelKey` helper, the four ~15-field override/overlay enumerations now share a `ModelPatch` type applied by a single `applyModelPatch(base, patch, transport)` core (the `merge` vs `replace` transport policies preserve the same-id custom-definition replacement semantics), and canonical-variant selection delegates to `@oh-my-pi/pi-catalog/identity`'s new `resolveCanonicalVariant` +- Resolver cleanup: five duplicated trailing-`:level` suffix parses collapsed into `splitThinkingSuffix`, the matching engine is now the documented `matchModel` core with the selector grammar and entry points layered on top, and `resolveCliModel`'s hand-rolled decomposed provider/id lookup reuses `findExactModelReferenceMatch`; runtime discovery tests split out of `test/model-registry.test.ts` into `test/model-discovery.test.ts` ### Fixed diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index ab8715664..aaa3f8b5c 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -51,6 +51,7 @@ "@oh-my-pi/omp-stats": "catalog:", "@oh-my-pi/pi-agent-core": "catalog:", "@oh-my-pi/pi-ai": "catalog:", + "@oh-my-pi/pi-catalog": "catalog:", "@oh-my-pi/pi-mnemopi": "catalog:", "@oh-my-pi/pi-natives": "catalog:", "@oh-my-pi/pi-tui": "catalog:", diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index ee8863d26..7a083222d 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -1,7 +1,7 @@ /** * CLI argument parsing and help display */ -import { type Effort, THINKING_EFFORTS } from "@oh-my-pi/pi-ai/effort"; +import { type Effort, THINKING_EFFORTS } from "@oh-my-pi/pi-catalog/effort"; import { APP_NAME, CONFIG_DIR_NAME, logger } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; import { parseEffort } from "../thinking"; diff --git a/packages/coding-agent/src/cli/auth-gateway-cli.ts b/packages/coding-agent/src/cli/auth-gateway-cli.ts index bc509f0e4..d6c6aca7b 100644 --- a/packages/coding-agent/src/cli/auth-gateway-cli.ts +++ b/packages/coding-agent/src/cli/auth-gateway-cli.ts @@ -24,14 +24,12 @@ import { type CredentialCompletionResult, completeSimple, DEFAULT_AUTH_GATEWAY_BIND, - type GeneratedProvider, - getBundledModels, - getBundledProviders, type Model, RemoteAuthCredentialStore, type SnapshotResponse, startAuthGateway, } from "@oh-my-pi/pi-ai"; +import { type GeneratedProvider, getBundledModels, getBundledProviders } from "@oh-my-pi/pi-catalog/models"; import { getConfigRootDir, isEnoent, VERSION } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; import { type AuthBrokerClientConfig, resolveAuthBrokerConfig } from "../session/auth-broker-config"; diff --git a/packages/coding-agent/src/cli/dry-balance-cli.ts b/packages/coding-agent/src/cli/dry-balance-cli.ts index 72cd26868..8bbca2462 100644 --- a/packages/coding-agent/src/cli/dry-balance-cli.ts +++ b/packages/coding-agent/src/cli/dry-balance-cli.ts @@ -12,10 +12,10 @@ import type { SimpleStreamOptions, } from "@oh-my-pi/pi-ai"; import { streamSimple } from "@oh-my-pi/pi-ai"; +import type { CanonicalModelVariant } from "@oh-my-pi/pi-catalog/identity"; import { replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui"; import { formatDuration, getProjectDir } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; -import type { CanonicalModelVariant } from "../config/model-equivalence"; import { type CanonicalModelQueryOptions, ModelRegistry } from "../config/model-registry"; import { formatModelString, diff --git a/packages/coding-agent/src/cli/list-models.ts b/packages/coding-agent/src/cli/list-models.ts index e9d40de34..78edfd66f 100644 --- a/packages/coding-agent/src/cli/list-models.ts +++ b/packages/coding-agent/src/cli/list-models.ts @@ -1,7 +1,8 @@ /** * List available models with optional fuzzy search */ -import { type Api, getSupportedEfforts, type Model } from "@oh-my-pi/pi-ai"; +import type { Api, Model } from "@oh-my-pi/pi-ai"; +import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking"; import { fuzzyFilter } from "@oh-my-pi/pi-tui"; import { formatNumber } from "@oh-my-pi/pi-utils"; import type { ModelRegistry } from "../config/model-registry"; diff --git a/packages/coding-agent/src/commands/complete.ts b/packages/coding-agent/src/commands/complete.ts index aae52d499..f9eb67c53 100644 --- a/packages/coding-agent/src/commands/complete.ts +++ b/packages/coding-agent/src/commands/complete.ts @@ -8,7 +8,7 @@ * first field. The import surface is kept deliberately narrow so a TAB press * doesn't pay for the full agent boot. */ -import { type GeneratedProvider, getBundledModels, getBundledProviders } from "@oh-my-pi/pi-ai/models"; +import { type GeneratedProvider, getBundledModels, getBundledProviders } from "@oh-my-pi/pi-catalog/models"; import { Command } from "@oh-my-pi/pi-utils/cli"; import { SessionManager } from "../session/session-manager"; diff --git a/packages/coding-agent/src/commands/launch.ts b/packages/coding-agent/src/commands/launch.ts index a3cc59719..12194117f 100644 --- a/packages/coding-agent/src/commands/launch.ts +++ b/packages/coding-agent/src/commands/launch.ts @@ -2,7 +2,7 @@ * Root command for the coding agent CLI. */ -import { THINKING_EFFORTS } from "@oh-my-pi/pi-ai/effort"; +import { THINKING_EFFORTS } from "@oh-my-pi/pi-catalog/effort"; import { APP_NAME } from "@oh-my-pi/pi-utils"; import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli"; import { parseArgs } from "../cli/args"; diff --git a/packages/coding-agent/src/commit/model-selection.ts b/packages/coding-agent/src/commit/model-selection.ts index d5ffa73c3..a470abe78 100644 --- a/packages/coding-agent/src/commit/model-selection.ts +++ b/packages/coding-agent/src/commit/model-selection.ts @@ -1,7 +1,6 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Api, ApiKey, Model } from "@oh-my-pi/pi-ai"; import type { ApiKeyResolverRegistry } from "../config/api-key-resolver"; -import { MODEL_ROLE_IDS } from "../config/model-registry"; import { getModelMatchPreferences, type ModelLookupRegistry, @@ -9,6 +8,7 @@ import { resolveModelRoleValue, resolveRoleSelection, } from "../config/model-resolver"; +import { MODEL_ROLE_IDS } from "../config/model-roles"; import type { Settings } from "../config/settings"; import MODEL_PRIO from "../priority.json" with { type: "json" }; diff --git a/packages/coding-agent/src/config/model-discovery.ts b/packages/coding-agent/src/config/model-discovery.ts new file mode 100644 index 000000000..68021841a --- /dev/null +++ b/packages/coding-agent/src/config/model-discovery.ts @@ -0,0 +1,553 @@ +/** + * HTTP discovery protocols for configured and implicit providers — ollama, + * llama.cpp, lm-studio, openai-models-list, and new-api/one-api-style proxies. + * `ModelRegistry` owns the orchestration (status, state, caching) and calls + * `discoverModelsByProviderType` with a `DiscoveryContext`; built-in provider + * discovery lives in pi-catalog's provider-models. + */ +import type { FetchImpl } from "@oh-my-pi/pi-ai"; +import type { Api, Model } from "@oh-my-pi/pi-ai/types"; +import { + getBundledModelReferenceIndex, + resolveModelReference, + stripBracketedModelIdAffixes, +} from "@oh-my-pi/pi-catalog/identity"; +import { enrichModelThinking } from "@oh-my-pi/pi-catalog/model-thinking"; +import { isRecord } from "@oh-my-pi/pi-utils"; +import type { ProviderDiscovery } from "./models-config-schema"; + +// Default cap on `max_tokens` for auto-discovered models that do not advertise +// their own output limit (OpenAI-models-list, Ollama, llama.cpp, new-api/ +// one-api proxies). 32K matches the upper end of what mainstream +// OpenAI-compatible providers (DeepSeek, MiMo, OpenRouter, etc.) actually +// accept and keeps `min(contextWindow, …)` honoring smaller local windows. +// Conservative caps below this caused providers to drop the connection +// mid-stream when models hit the cap on legitimate large tool calls (see +// issue #1528: `write` payloads >~5KB on deepseek-v4-pro surfaced as +// "socket connection was closed unexpectedly"). +export const DISCOVERY_DEFAULT_MAX_TOKENS = 32_768; + +const DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434"; +const OLLAMA_HOST_DEFAULT_PORT = "11434"; + +function normalizeOllamaHostEnv(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) return undefined; + const candidate = trimmed.includes("://") + ? trimmed + : trimmed.startsWith("//") + ? `http:${trimmed}` + : trimmed.startsWith(":") + ? `http://127.0.0.1${trimmed}` + : `http://${trimmed}`; + try { + const parsed = new URL(candidate); + if (!parsed.hostname || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) { + return undefined; + } + if (!parsed.port && parsed.protocol === "http:") { + parsed.port = OLLAMA_HOST_DEFAULT_PORT; + } + return `${parsed.protocol}//${parsed.host}`; + } catch { + return undefined; + } +} + +export function getImplicitOllamaBaseUrl(): string { + const baseUrl = Bun.env.OLLAMA_BASE_URL?.trim(); + return baseUrl || normalizeOllamaHostEnv(Bun.env.OLLAMA_HOST) || DEFAULT_OLLAMA_BASE_URL; +} + +export function getOllamaContextLengthOverride(): number | undefined { + const value = Bun.env.OLLAMA_CONTEXT_LENGTH?.trim(); + if (!value) return undefined; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; +} + +// Anthropic-safe variant of the discovery cap. The Anthropic stream converter +// in `packages/ai/src/providers/anthropic.ts` derives the request limit as +// `(model.maxTokens / 3) | 0`, so the 32K default would surface as 10,922 +// requested output tokens — above the 8,192 hard cap on classic Claude 3.x +// Sonnet/Haiku/Opus endpoints. Discovered models routed through +// `anthropic-messages` (proxy `supported_endpoint_types: ["anthropic"]` or a +// custom provider with `api: anthropic-messages` + openai-models-list +// discovery) fall back to this conservative value. +const DISCOVERY_DEFAULT_MAX_TOKENS_ANTHROPIC = 8_192; + +/** Routes discovered-model `maxTokens` defaults around Anthropic's 3× output divisor. */ +export function discoveryDefaultMaxTokens(api: Api | undefined): number { + return api === "anthropic-messages" ? DISCOVERY_DEFAULT_MAX_TOKENS_ANTHROPIC : DISCOVERY_DEFAULT_MAX_TOKENS; +} + +export interface DiscoveryProviderConfig { + provider: string; + api: Api; + baseUrl?: string; + headers?: Record; + compat?: Model["compat"]; + discovery: ProviderDiscovery; + optional?: boolean; +} + +/** Registry-provided capabilities the protocol probes need; never the registry itself. */ +export interface DiscoveryContext { + /** Injected fetch implementation (tests stub this). */ + fetch: FetchImpl; + /** + * Resolve a provider's API key for `Authorization: Bearer …`. Returns + * undefined when no key is stored or it is a local/no-auth sentinel. + */ + getBearerApiKey(provider: string): Promise; +} + +type OllamaDiscoveredModelMetadata = { + reasoning: boolean; + input: ("text" | "image")[]; + contextWindow?: number; +}; + +type LlamaCppDiscoveredServerMetadata = { + contextWindow?: number; + input?: ("text" | "image")[]; +}; + +function toPositiveNumberOrUndefined(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return value; + } + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + return undefined; +} + +function extractOllamaContextWindow(payload: Record): number | undefined { + const modelInfo = payload.model_info; + if (isRecord(modelInfo)) { + for (const [key, value] of Object.entries(modelInfo)) { + if (key === "context_length" || key.endsWith(".context_length")) { + const contextWindow = toPositiveNumberOrUndefined(value); + if (contextWindow !== undefined) { + return contextWindow; + } + } + } + } + + const parameters = payload.parameters; + if (typeof parameters !== "string") { + return undefined; + } + const match = parameters.match(/(?:^|\n)\s*num_ctx\s+(\d+)\s*(?:$|\n)/m); + return match ? toPositiveNumberOrUndefined(match[1]) : undefined; +} + +function extractLlamaCppContextWindow(payload: Record): number | undefined { + const generationSettings = payload.default_generation_settings; + if (isRecord(generationSettings)) { + const contextWindow = toPositiveNumberOrUndefined(generationSettings.n_ctx); + if (contextWindow !== undefined) { + return contextWindow; + } + } + return toPositiveNumberOrUndefined(payload.n_ctx); +} + +function extractLlamaCppInputCapabilities(payload: Record): ("text" | "image")[] | undefined { + const modalities = payload.modalities; + if (!isRecord(modalities)) { + return undefined; + } + return modalities.vision === true ? ["text", "image"] : ["text"]; +} + +export function discoverModelsByProviderType( + providerConfig: DiscoveryProviderConfig, + ctx: DiscoveryContext, +): Promise[]> { + switch (providerConfig.discovery.type) { + case "ollama": + return discoverOllamaModels(providerConfig, ctx); + case "llama.cpp": + return discoverLlamaCppModels(providerConfig, ctx); + case "lm-studio": + case "openai-models-list": + return discoverOpenAIModelsList(providerConfig, ctx); + case "proxy": + return discoverProxyModels(providerConfig, ctx); + } +} + +async function discoverOllamaModelMetadata( + ctx: DiscoveryContext, + endpoint: string, + modelId: string, + headers: Record | undefined, +): Promise { + const showUrl = `${endpoint}/api/show`; + try { + const response = await ctx.fetch(showUrl, { + method: "POST", + headers: { ...(headers ?? {}), "Content-Type": "application/json" }, + body: JSON.stringify({ model: modelId }), + signal: AbortSignal.timeout(150), + }); + if (!response.ok) { + return null; + } + const payload = (await response.json()) as unknown; + if (!isRecord(payload)) { + return null; + } + const contextWindow = extractOllamaContextWindow(payload); + const capabilities = payload.capabilities; + if (Array.isArray(capabilities)) { + const normalized = new Set( + capabilities.flatMap(capability => (typeof capability === "string" ? [capability.toLowerCase()] : [])), + ); + const supportsVision = normalized.has("vision") || normalized.has("image"); + return { + reasoning: normalized.has("thinking"), + input: supportsVision ? ["text", "image"] : ["text"], + contextWindow, + }; + } + if (!isRecord(capabilities)) { + return { + reasoning: false, + input: ["text"], + contextWindow, + }; + } + const supportsVision = capabilities.vision === true || capabilities.image === true; + return { + reasoning: capabilities.thinking === true, + input: supportsVision ? ["text", "image"] : ["text"], + contextWindow, + }; + } catch { + return null; + } +} + +export async function discoverOllamaModels( + providerConfig: DiscoveryProviderConfig, + ctx: DiscoveryContext, +): Promise[]> { + const endpoint = normalizeOllamaBaseUrl(providerConfig.baseUrl); + const tagsUrl = `${endpoint}/api/tags`; + const headers = { ...(providerConfig.headers ?? {}) }; + const response = await ctx.fetch(tagsUrl, { + headers, + signal: AbortSignal.timeout(250), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status} from ${tagsUrl}`); + } + const payload = (await response.json()) as { models?: Array<{ name?: string; model?: string }> }; + const entries = (payload.models ?? []).flatMap(item => { + const id = item.model || item.name; + return id ? [{ id, name: item.name || id }] : []; + }); + const metadataById = new Map( + await Promise.all( + entries.map( + async entry => [entry.id, await discoverOllamaModelMetadata(ctx, endpoint, entry.id, headers)] as const, + ), + ), + ); + return entries.map(entry => { + const metadata = metadataById.get(entry.id); + return enrichModelThinking({ + id: entry.id, + name: entry.name, + api: providerConfig.api, + provider: providerConfig.provider, + baseUrl: `${endpoint}/v1`, + reasoning: metadata?.reasoning ?? false, + input: metadata?.input ?? ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: metadata?.contextWindow ?? 128000, + maxTokens: Math.min(metadata?.contextWindow ?? Number.POSITIVE_INFINITY, DISCOVERY_DEFAULT_MAX_TOKENS), + headers: providerConfig.headers, + }); + }); +} + +async function discoverLlamaCppServerMetadata( + ctx: DiscoveryContext, + baseUrl: string, + headers: Record | undefined, +): Promise { + const propsUrl = `${toLlamaCppNativeBaseUrl(baseUrl)}/props`; + try { + const response = await ctx.fetch(propsUrl, { + headers, + signal: AbortSignal.timeout(150), + }); + if (!response.ok) { + return null; + } + const payload = (await response.json()) as unknown; + if (!isRecord(payload)) { + return null; + } + return { + contextWindow: extractLlamaCppContextWindow(payload), + input: extractLlamaCppInputCapabilities(payload), + }; + } catch { + return null; + } +} + +export async function discoverLlamaCppModels( + providerConfig: DiscoveryProviderConfig, + ctx: DiscoveryContext, +): Promise[]> { + const baseUrl = normalizeLlamaCppBaseUrl(providerConfig.baseUrl); + const modelsUrl = `${baseUrl}/models`; + + const headers: Record = { ...(providerConfig.headers ?? {}) }; + const apiKey = await ctx.getBearerApiKey(providerConfig.provider); + if (apiKey) { + headers.Authorization = `Bearer ${apiKey}`; + } + + const [response, serverMetadata] = await Promise.all([ + ctx.fetch(modelsUrl, { + headers, + signal: AbortSignal.timeout(250), + }), + discoverLlamaCppServerMetadata(ctx, baseUrl, headers), + ]); + if (!response.ok) { + throw new Error(`HTTP ${response.status} from ${modelsUrl}`); + } + const payload = (await response.json()) as { data?: Array<{ id: string }> }; + const models = payload.data ?? []; + const discovered: Model[] = []; + for (const item of models) { + const id = item.id; + if (!id) continue; + discovered.push( + enrichModelThinking({ + id, + name: id, + api: providerConfig.api, + provider: providerConfig.provider, + baseUrl, + reasoning: false, + input: serverMetadata?.input ?? ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: serverMetadata?.contextWindow ?? 128000, + maxTokens: Math.min( + serverMetadata?.contextWindow ?? Number.POSITIVE_INFINITY, + DISCOVERY_DEFAULT_MAX_TOKENS, + ), + headers, + compat: { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + }, + }), + ); + } + return discovered; +} + +export async function discoverOpenAIModelsList( + providerConfig: DiscoveryProviderConfig, + ctx: DiscoveryContext, +): Promise[]> { + const baseUrl = normalizeOpenAIModelsListBaseUrl(providerConfig.baseUrl); + const modelsUrl = `${baseUrl}/models`; + + const headers: Record = { ...(providerConfig.headers ?? {}) }; + const apiKey = await ctx.getBearerApiKey(providerConfig.provider); + if (apiKey) { + headers.Authorization = `Bearer ${apiKey}`; + } + + const response = await ctx.fetch(modelsUrl, { + headers, + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status} from ${modelsUrl}`); + } + const payload = (await response.json()) as { data?: Array<{ id: string }> }; + const models = payload.data ?? []; + const discovered: Model[] = []; + for (const item of models) { + const id = item.id; + if (!id) continue; + discovered.push( + enrichModelThinking({ + id, + name: id, + api: providerConfig.api, + provider: providerConfig.provider, + baseUrl, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: discoveryDefaultMaxTokens(providerConfig.api), + headers, + compat: { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + }, + }), + ); + } + return discovered; +} + +/** + * Discover models from an Anthropic+OpenAI-compatible reseller proxy that + * exposes both `/v1/messages` and `/v1/chat/completions`, advertising each + * model's wire capabilities through `supported_endpoint_types` on + * `GET /v1/models` (new-api / one-api-style proxies). + * + * Routing per model: + * supported_endpoint_types: ["anthropic", ...] -> api: "anthropic-messages" + * supported_endpoint_types: ["openai"] -> api: "openai-completions" + * missing / neither -> provider-level api fallback + * + * Anthropic models share the same baseUrl; the Anthropic SDK strips a + * trailing `/v1` itself before appending `/v1/messages`, so the discovery + * URL (which ends in `/v1`) round-trips correctly. + */ +export async function discoverProxyModels( + providerConfig: DiscoveryProviderConfig, + ctx: DiscoveryContext, +): Promise[]> { + const baseUrl = normalizeOpenAIModelsListBaseUrl(providerConfig.baseUrl); + const modelsUrl = `${baseUrl}/models`; + + const headers: Record = { ...(providerConfig.headers ?? {}) }; + const apiKey = await ctx.getBearerApiKey(providerConfig.provider); + if (apiKey) { + headers.Authorization = `Bearer ${apiKey}`; + } + + const response = await ctx.fetch(modelsUrl, { + headers, + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status} from ${modelsUrl}`); + } + const payload = (await response.json()) as { + data?: Array<{ id?: string; name?: string; supported_endpoint_types?: string[] }>; + }; + const items = payload.data ?? []; + const discovered: Model[] = []; + for (const item of items) { + const id = item.id; + if (!id) continue; + const endpoints = item.supported_endpoint_types ?? []; + const api: Api | undefined = endpoints.includes("anthropic") + ? "anthropic-messages" + : endpoints.includes("openai") + ? "openai-completions" + : providerConfig.api; + if (!api) continue; + const isAnthropic = api === "anthropic-messages"; + const reference = resolveModelReference(id, getBundledModelReferenceIndex()); + const discoveryName = typeof item.name === "string" ? item.name.trim() : ""; + const displayName = + reference?.name ?? + (discoveryName && discoveryName !== id ? discoveryName : undefined) ?? + stripBracketedModelIdAffixes(id) ?? + id; + discovered.push( + enrichModelThinking({ + id, + name: displayName, + api, + provider: providerConfig.provider, + baseUrl, + reasoning: reference?.reasoning ?? false, + thinking: reference?.thinking, + input: reference?.input ?? ["text"], + // Proxy pricing is provider-specific and usually does not match + // upstream bundled catalogs, so keep costs local-unknown even when + // we successfully recover the upstream model identity. + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: reference?.contextWindow ?? 128000, + maxTokens: reference?.maxTokens ?? discoveryDefaultMaxTokens(api), + headers, + // OpenAI-compat fields are no-ops on anthropic models; the + // Anthropic SDK ignores them. Provider-level disableStrictTools + // flows in via #applyProviderCompat for the third-party-Anthropic + // path. Cross-wire bundled compat is intentionally not copied: + // request-shaping fields are provider-wire specific. + compat: isAnthropic + ? undefined + : { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + }, + }), + ); + } + return discovered; +} + +function normalizeLlamaCppBaseUrl(baseUrl?: string): string { + const defaultBaseUrl = "http://127.0.0.1:8080"; + const raw = baseUrl || defaultBaseUrl; + try { + const parsed = new URL(raw); + const trimmedPath = parsed.pathname.replace(/\/+$/g, ""); + return `${parsed.protocol}//${parsed.host}${trimmedPath}`; + } catch { + return raw; + } +} + +function toLlamaCppNativeBaseUrl(baseUrl: string): string { + try { + const parsed = new URL(baseUrl); + const trimmedPath = parsed.pathname.replace(/\/+$/g, ""); + parsed.pathname = trimmedPath.endsWith("/v1") ? trimmedPath.slice(0, -3) || "/" : trimmedPath || "/"; + const normalized = `${parsed.protocol}//${parsed.host}${parsed.pathname}`; + return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized; + } catch { + return baseUrl.endsWith("/v1") ? baseUrl.slice(0, -3) : baseUrl; + } +} + +function normalizeOpenAIModelsListBaseUrl(baseUrl?: string): string { + const defaultBaseUrl = "http://127.0.0.1:1234/v1"; + const raw = baseUrl || defaultBaseUrl; + try { + const parsed = new URL(raw); + const trimmedPath = parsed.pathname.replace(/\/+$/g, ""); + parsed.pathname = trimmedPath.endsWith("/v1") ? trimmedPath || "/v1" : `${trimmedPath}/v1`; + return `${parsed.protocol}//${parsed.host}${parsed.pathname}`; + } catch { + return raw; + } +} + +function normalizeOllamaBaseUrl(baseUrl?: string): string { + const raw = baseUrl || DEFAULT_OLLAMA_BASE_URL; + try { + const parsed = new URL(raw); + return `${parsed.protocol}//${parsed.host}`; + } catch { + return DEFAULT_OLLAMA_BASE_URL; + } +} diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 284aad050..6ad89a15c 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -1,9 +1,15 @@ import * as path from "node:path"; import { registerCustomApi, unregisterCustomApis } from "@oh-my-pi/pi-ai/api-registry"; -import { readModelCache } from "@oh-my-pi/pi-ai/model-cache"; -import { createModelManager, type ModelManagerOptions, type ModelRefreshStrategy } from "@oh-my-pi/pi-ai/model-manager"; -import { enrichModelThinking } from "@oh-my-pi/pi-ai/model-thinking"; -import { getBundledModels, getBundledProviders } from "@oh-my-pi/pi-ai/models"; +import type { Api, Context, Model, SimpleStreamOptions, ThinkingConfig } from "@oh-my-pi/pi-ai/types"; +import type { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; +import { readModelCache } from "@oh-my-pi/pi-catalog/model-cache"; +import { + createModelManager, + type ModelManagerOptions, + type ModelRefreshStrategy, +} from "@oh-my-pi/pi-catalog/model-manager"; +import { enrichModelThinking } from "@oh-my-pi/pi-catalog/model-thinking"; +import { getBundledModels, getBundledProviders } from "@oh-my-pi/pi-catalog/models"; import { googleAntigravityModelManagerOptions, googleGeminiCliModelManagerOptions, @@ -11,79 +17,12 @@ import { PROVIDER_DESCRIPTORS, UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS, -} from "@oh-my-pi/pi-ai/provider-models"; -import type { Api, Context, Model, SimpleStreamOptions, ThinkingConfig } from "@oh-my-pi/pi-ai/types"; -import type { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; +} from "@oh-my-pi/pi-catalog/provider-models"; // Sentinel for local-only OAuth token (LM Studio, vLLM) — declared inline to avoid loading // any provider module at startup. Must match `DEFAULT_LOCAL_TOKEN` in oauth/lm-studio.ts. const DEFAULT_LOCAL_TOKEN = "lm-studio-local"; -// Default cap on `max_tokens` for auto-discovered models that do not advertise -// their own output limit (OpenAI-models-list, Ollama, llama.cpp, new-api/ -// one-api proxies). 32K matches the upper end of what mainstream -// OpenAI-compatible providers (DeepSeek, MiMo, OpenRouter, etc.) actually -// accept and keeps `min(contextWindow, …)` honoring smaller local windows. -// Conservative caps below this caused providers to drop the connection -// mid-stream when models hit the cap on legitimate large tool calls (see -// issue #1528: `write` payloads >~5KB on deepseek-v4-pro surfaced as -// "socket connection was closed unexpectedly"). -const DISCOVERY_DEFAULT_MAX_TOKENS = 32_768; - -const DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434"; -const OLLAMA_HOST_DEFAULT_PORT = "11434"; - -function normalizeOllamaHostEnv(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - if (!trimmed) return undefined; - const candidate = trimmed.includes("://") - ? trimmed - : trimmed.startsWith("//") - ? `http:${trimmed}` - : trimmed.startsWith(":") - ? `http://127.0.0.1${trimmed}` - : `http://${trimmed}`; - try { - const parsed = new URL(candidate); - if (!parsed.hostname || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) { - return undefined; - } - if (!parsed.port && parsed.protocol === "http:") { - parsed.port = OLLAMA_HOST_DEFAULT_PORT; - } - return `${parsed.protocol}//${parsed.host}`; - } catch { - return undefined; - } -} - -function getImplicitOllamaBaseUrl(): string { - const baseUrl = Bun.env.OLLAMA_BASE_URL?.trim(); - return baseUrl || normalizeOllamaHostEnv(Bun.env.OLLAMA_HOST) || DEFAULT_OLLAMA_BASE_URL; -} - -function getOllamaContextLengthOverride(): number | undefined { - const value = Bun.env.OLLAMA_CONTEXT_LENGTH?.trim(); - if (!value) return undefined; - const parsed = Number(value); - return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; -} - -// Anthropic-safe variant of the discovery cap. The Anthropic stream converter -// in `packages/ai/src/providers/anthropic.ts` derives the request limit as -// `(model.maxTokens / 3) | 0`, so the 32K default would surface as 10,922 -// requested output tokens — above the 8,192 hard cap on classic Claude 3.x -// Sonnet/Haiku/Opus endpoints. Discovered models routed through -// `anthropic-messages` (proxy `supported_endpoint_types: ["anthropic"]` or a -// custom provider with `api: anthropic-messages` + openai-models-list -// discovery) fall back to this conservative value. -const DISCOVERY_DEFAULT_MAX_TOKENS_ANTHROPIC = 8_192; - -/** Routes discovered-model `maxTokens` defaults around Anthropic's 3× output divisor. */ -function discoveryDefaultMaxTokens(api: Api | undefined): number { - return api === "anthropic-messages" ? DISCOVERY_DEFAULT_MAX_TOKENS_ANTHROPIC : DISCOVERY_DEFAULT_MAX_TOKENS; -} - const SPECIAL_MODEL_MANAGER_PROVIDER_IDS: readonly string[] = [ "google-antigravity", "google-gemini-cli", @@ -98,35 +37,37 @@ const STARTUP_MODEL_CACHE_PROVIDER_IDS: readonly string[] = [ import type { ApiKeyResolver, FetchImpl } from "@oh-my-pi/pi-ai"; import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/oauth/types"; -import { isRecord, logger } from "@oh-my-pi/pi-utils"; -import { parseModelString, resolveProviderModelReference } from "../config/model-resolver"; -import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme"; -import type { AuthStorage, OAuthCredential } from "../session/auth-storage"; -import { type ApiKeyResolverOptions, createApiKeyResolver } from "./api-key-resolver"; -import { type ConfigError, ConfigFile } from "./config-file"; import { buildCanonicalModelIndex, + buildCanonicalModelOrder, + buildModelProviderPriorityRank, type CanonicalModelIndex, type CanonicalModelRecord, type CanonicalModelVariant, + type CanonicalVariantPreferences, formatCanonicalVariantSelector, + getBundledCanonicalReferenceData, + getBundledModelReferenceIndex, type ModelEquivalenceConfig, -} from "./model-equivalence"; + resolveCanonicalVariant, + resolveModelReference, +} from "@oh-my-pi/pi-catalog/identity"; +import { isRecord, logger } from "@oh-my-pi/pi-utils"; +import { parseModelString, resolveProviderModelReference } from "../config/model-resolver"; +import type { AuthStorage, OAuthCredential } from "../session/auth-storage"; +import { type ApiKeyResolverOptions, createApiKeyResolver } from "./api-key-resolver"; +import type { ConfigError, ConfigFile } from "./config-file"; import { - getBracketStrippedModelIdCandidates, - getLongestModelLikeIdSegment, - getModelLikeIdSegments, - stripBracketedModelIdAffixes, -} from "./model-id-affixes"; -import { buildModelProviderPriorityRank } from "./model-provider-priority"; -import { - type ModelOverride, - type ModelsConfig, - ModelsConfigSchema, - type ProviderAuthMode, - type ProviderDiscovery, -} from "./models-config-schema"; -import { type Settings, settings } from "./settings"; + DISCOVERY_DEFAULT_MAX_TOKENS, + type DiscoveryContext, + type DiscoveryProviderConfig, + discoverModelsByProviderType, + getImplicitOllamaBaseUrl, + getOllamaContextLengthOverride, +} from "./model-discovery"; +import { ModelsConfigFile, type ProviderValidationModel, validateProviderConfiguration } from "./models-config"; +import type { ModelOverride, ModelsConfig, ProviderAuthMode } from "./models-config-schema"; +import { settings } from "./settings"; export type { CanonicalModelIndex, CanonicalModelRecord, CanonicalModelVariant, ModelEquivalenceConfig }; @@ -136,189 +77,6 @@ export function isAuthenticated(apiKey: string | undefined | null): apiKey is st return Boolean(apiKey) && apiKey !== kNoAuth; } -export type ModelRole = "default" | "smol" | "slow" | "vision" | "plan" | "designer" | "commit" | "task"; - -export interface ModelRoleInfo { - tag?: string; - name: string; - color?: ThemeColor; -} - -export const MODEL_ROLES: Record = { - default: { tag: "DEFAULT", name: "Default", color: "success" }, - smol: { tag: "SMOL", name: "Fast", color: "warning" }, - slow: { tag: "SLOW", name: "Thinking", color: "accent" }, - vision: { tag: "VISION", name: "Vision", color: "error" }, - plan: { tag: "PLAN", name: "Architect", color: "muted" }, - designer: { tag: "DESIGNER", name: "Designer", color: "muted" }, - commit: { tag: "COMMIT", name: "Commit", color: "dim" }, - task: { tag: "TASK", name: "Subtask", color: "muted" }, -}; - -export const MODEL_ROLE_IDS: ModelRole[] = ["default", "smol", "slow", "vision", "plan", "designer", "commit", "task"]; - -/** Alias for ModelRoleInfo - used for both built-in and custom roles */ -export type RoleInfo = ModelRoleInfo; - -/** - * Return the canonical set of known roles for selector/carousel UI. - * - * Built-ins always come first. Configured cycle order, model assignments, and - * tag metadata can introduce additional custom roles without requiring duplicate - * entries across settings. - */ -export function getKnownRoleIds(settings: Settings): string[] { - const roles = [...MODEL_ROLE_IDS] as string[]; - const seen = new Set(roles); - const addRole = (role: string) => { - if (seen.has(role)) return; - seen.add(role); - roles.push(role); - }; - - for (const role of settings.get("cycleOrder")) addRole(role); - for (const role of Object.keys(settings.getModelRoles())) addRole(role); - for (const role of Object.keys(settings.get("modelTags"))) addRole(role); - - return roles; -} - -/** - * Get role info for a role name (built-in or custom). - * Configured metadata overrides built-in defaults when present. - */ -export function getRoleInfo(role: string, settings: Settings): RoleInfo { - const builtIn = role in MODEL_ROLES ? MODEL_ROLES[role as ModelRole] : undefined; - const configured = settings.get("modelTags")[role]; - - if (configured) { - return { - tag: builtIn?.tag, - name: configured.name || builtIn?.name || role, - color: configured.color && isValidThemeColor(configured.color) ? configured.color : builtIn?.color, - }; - } - - if (builtIn) return builtIn; - - return { name: role, color: "muted" }; -} - -type ProviderValidationMode = "models-config" | "runtime-register"; - -interface ProviderValidationModel { - id: string; - api?: Api; - contextWindow?: number; - maxTokens?: number; -} - -interface ProviderValidationConfig { - baseUrl?: string; - headers?: Record; - apiKey?: string; - api?: Api; - auth?: ProviderAuthMode; - oauthConfigured?: boolean; - discovery?: ProviderDiscovery; - compat?: Model["compat"]; - disableStrictTools?: boolean; - modelOverrides?: Record; - models: ProviderValidationModel[]; -} - -function validateProviderConfiguration( - providerName: string, - config: ProviderValidationConfig, - mode: ProviderValidationMode, -): void { - const hasProviderApi = !!config.api; - const models = config.models; - - if (models.length === 0) { - if (mode === "models-config") { - const hasModelOverrides = config.modelOverrides && Object.keys(config.modelOverrides).length > 0; - if ( - !config.baseUrl && - !config.headers && - !config.compat && - !config.apiKey && - !config.disableStrictTools && - !hasModelOverrides && - !config.discovery - ) { - throw new Error( - `Provider ${providerName}: must specify "baseUrl", "headers", "apiKey", "compat", "disableStrictTools", "modelOverrides", "discovery", or "models"`, - ); - } - } - } else { - if (!config.baseUrl) { - throw new Error(`Provider ${providerName}: "baseUrl" is required when defining custom models.`); - } - const requiresAuth = - mode === "runtime-register" - ? !config.apiKey && !config.oauthConfigured - : !config.apiKey && (config.auth ?? "apiKey") !== "none"; - if (requiresAuth) { - throw new Error( - mode === "runtime-register" - ? `Provider ${providerName}: "apiKey" or "oauth" is required when defining models.` - : `Provider ${providerName}: "apiKey" is required when defining custom models unless auth is "none".`, - ); - } - } - - if (mode === "models-config" && config.discovery && !config.api && config.discovery.type !== "proxy") { - throw new Error(`Provider ${providerName}: "api" is required when discovery is enabled at provider level.`); - } - - for (const modelDef of models) { - if (!hasProviderApi && !modelDef.api) { - throw new Error( - mode === "runtime-register" - ? `Provider ${providerName}, model ${modelDef.id}: no "api" specified.` - : `Provider ${providerName}, model ${modelDef.id}: no "api" specified. Set at provider or model level.`, - ); - } - if (!modelDef.id) { - throw new Error(`Provider ${providerName}: model missing "id"`); - } - if (mode === "models-config") { - if (modelDef.contextWindow !== undefined && modelDef.contextWindow <= 0) { - throw new Error(`Provider ${providerName}, model ${modelDef.id}: invalid contextWindow`); - } - if (modelDef.maxTokens !== undefined && modelDef.maxTokens <= 0) { - throw new Error(`Provider ${providerName}, model ${modelDef.id}: invalid maxTokens`); - } - } - } -} - -export const ModelsConfigFile = new ConfigFile("models", ModelsConfigSchema).withValidation( - "models", - config => { - for (const [providerName, providerConfig] of Object.entries(config.providers ?? {})) { - validateProviderConfiguration( - providerName, - { - baseUrl: providerConfig.baseUrl, - headers: providerConfig.headers, - apiKey: providerConfig.apiKey, - api: providerConfig.api as Api | undefined, - auth: (providerConfig.auth ?? "apiKey") as ProviderAuthMode, - discovery: providerConfig.discovery as ProviderDiscovery | undefined, - compat: providerConfig.compat, - disableStrictTools: providerConfig.disableStrictTools, - modelOverrides: providerConfig.modelOverrides, - models: (providerConfig.models ?? []) as ProviderValidationModel[], - }, - "models-config", - ); - } - }, -); - /** Provider override config (baseUrl, headers, apiKey, compat, transport) without custom models */ interface ProviderOverride { baseUrl?: string; @@ -396,14 +154,32 @@ function dropProviderModels(models: readonly Model[], providers: ReadonlySe return models.filter(model => !providers.has(model.provider)); } -interface DiscoveryProviderConfig { - provider: string; - api: Api; - baseUrl?: string; - headers?: Record; - compat?: Model["compat"]; - discovery: ProviderDiscovery; - optional?: boolean; +/** + * Merge `incoming` entries into a copy of `base`, keyed by `provider`+`id`. + * Matches are replaced with `combine(existing, entry)`; new entries are + * appended as `combine(undefined, entry)`. + */ +function mergeByModelKey( + base: readonly Model[], + incoming: readonly T[], + combine: (existing: Model | undefined, entry: T) => Model, +): Model[] { + const merged = [...base]; + const indexByKey = new Map(); + for (let i = 0; i < merged.length; i += 1) { + indexByKey.set(`${merged[i].provider}\u0000${merged[i].id}`, i); + } + for (const entry of incoming) { + const key = `${entry.provider}\u0000${entry.id}`; + const existingIndex = indexByKey.get(key); + if (existingIndex !== undefined) { + merged[existingIndex] = combine(merged[existingIndex], entry); + } else { + merged.push(combine(undefined, entry)); + indexByKey.set(key, merged.length - 1); + } + } + return merged; } interface BuiltInDiscoveryResult { @@ -447,17 +223,6 @@ interface CustomModelsResult { found: boolean; } -type OllamaDiscoveredModelMetadata = { - reasoning: boolean; - input: ("text" | "image")[]; - contextWindow?: number; -}; - -type LlamaCppDiscoveredServerMetadata = { - contextWindow?: number; - input?: ("text" | "image")[]; -}; - /** * Resolve an API key config value to an actual key. * Checks environment variable first, then treats as literal. @@ -468,59 +233,6 @@ function resolveApiKeyConfig(keyConfig: string): string | undefined { return keyConfig; } -function toPositiveNumberOrUndefined(value: unknown): number | undefined { - if (typeof value === "number" && Number.isFinite(value) && value > 0) { - return value; - } - if (typeof value === "string" && value.trim()) { - const parsed = Number(value); - if (Number.isFinite(parsed) && parsed > 0) { - return parsed; - } - } - return undefined; -} - -function extractOllamaContextWindow(payload: Record): number | undefined { - const modelInfo = payload.model_info; - if (isRecord(modelInfo)) { - for (const [key, value] of Object.entries(modelInfo)) { - if (key === "context_length" || key.endsWith(".context_length")) { - const contextWindow = toPositiveNumberOrUndefined(value); - if (contextWindow !== undefined) { - return contextWindow; - } - } - } - } - - const parameters = payload.parameters; - if (typeof parameters !== "string") { - return undefined; - } - const match = parameters.match(/(?:^|\n)\s*num_ctx\s+(\d+)\s*(?:$|\n)/m); - return match ? toPositiveNumberOrUndefined(match[1]) : undefined; -} - -function extractLlamaCppContextWindow(payload: Record): number | undefined { - const generationSettings = payload.default_generation_settings; - if (isRecord(generationSettings)) { - const contextWindow = toPositiveNumberOrUndefined(generationSettings.n_ctx); - if (contextWindow !== undefined) { - return contextWindow; - } - } - return toPositiveNumberOrUndefined(payload.n_ctx); -} - -function extractLlamaCppInputCapabilities(payload: Record): ("text" | "image")[] | undefined { - const modalities = payload.modalities; - if (!isRecord(modalities)) { - return undefined; - } - return modalities.vision === true ? ["text", "image"] : ["text"]; -} - function extractGoogleOAuthToken(value: string | undefined): string | undefined { if (!isAuthenticated(value)) return undefined; try { @@ -579,41 +291,17 @@ function mergeCompat( return merged as TBase & TOverride; } -function applyModelOverride(model: Model, override: ModelOverride): Model { - const result = { ...model }; - if (override.name !== undefined) result.name = override.name; - if (override.reasoning !== undefined) result.reasoning = override.reasoning; - if (override.thinking !== undefined) result.thinking = override.thinking as ThinkingConfig; - if (override.input !== undefined) result.input = override.input as ("text" | "image")[]; - if (override.contextWindow !== undefined) result.contextWindow = override.contextWindow; - if (override.maxTokens !== undefined) result.maxTokens = override.maxTokens; - if (override.omitMaxOutputTokens !== undefined) result.omitMaxOutputTokens = override.omitMaxOutputTokens; - if (override.contextPromotionTarget !== undefined) result.contextPromotionTarget = override.contextPromotionTarget; - if (override.premiumMultiplier !== undefined) result.premiumMultiplier = override.premiumMultiplier; - if (override.cost) { - result.cost = { - input: override.cost.input ?? model.cost.input, - output: override.cost.output ?? model.cost.output, - cacheRead: override.cost.cacheRead ?? model.cost.cacheRead, - cacheWrite: override.cost.cacheWrite ?? model.cost.cacheWrite, - }; - } - if (override.headers) { - result.headers = { ...model.headers, ...override.headers }; - } - result.compat = mergeCompat(model.compat, override.compat); - return enrichModelThinking(result); -} - -interface CustomModelDefinitionLike { - id: string; +/** + * The patchable subset of `Model` fields shared by `modelOverrides` entries, + * custom model definitions, and parsed custom-model overlays. `undefined` + * always means "leave the base value alone". + */ +interface ModelPatch { name?: string; - api?: Api; - baseUrl?: string; reasoning?: boolean; thinking?: ThinkingConfig; input?: ("text" | "image")[]; - cost?: { input: number; output: number; cacheRead: number; cacheWrite: number }; + cost?: Partial["cost"]>; contextWindow?: number; maxTokens?: number; omitMaxOutputTokens?: boolean; @@ -623,29 +311,69 @@ interface CustomModelDefinitionLike { premiumMultiplier?: number; } +/** + * How a patch treats the base model's transport metadata (headers/compat): + * - `merge`: fold the patch into the base's (modelOverrides semantics). + * - `replace`: the patch owns transport wholesale — same-id custom definitions + * already folded provider-level headers/compat in during parsing, so bundled + * transport metadata must not be re-merged (see `#mergeCustomModels`). + */ +type ModelTransportPolicy = "merge" | "replace"; + +function applyModelPatch(base: Model, patch: ModelPatch, transport: ModelTransportPolicy): Model { + const result = { ...base }; + if (patch.name !== undefined) result.name = patch.name; + if (patch.reasoning !== undefined) result.reasoning = patch.reasoning; + if (patch.thinking !== undefined) result.thinking = patch.thinking; + if (patch.input !== undefined) result.input = patch.input; + if (patch.contextWindow !== undefined) result.contextWindow = patch.contextWindow; + if (patch.maxTokens !== undefined) result.maxTokens = patch.maxTokens; + if (patch.omitMaxOutputTokens !== undefined) result.omitMaxOutputTokens = patch.omitMaxOutputTokens; + if (patch.contextPromotionTarget !== undefined) result.contextPromotionTarget = patch.contextPromotionTarget; + if (patch.premiumMultiplier !== undefined) result.premiumMultiplier = patch.premiumMultiplier; + if (patch.cost) { + result.cost = { + input: patch.cost.input ?? base.cost.input, + output: patch.cost.output ?? base.cost.output, + cacheRead: patch.cost.cacheRead ?? base.cost.cacheRead, + cacheWrite: patch.cost.cacheWrite ?? base.cost.cacheWrite, + }; + } + if (transport === "merge") { + if (patch.headers) { + result.headers = { ...base.headers, ...patch.headers }; + } + result.compat = mergeCompat(base.compat, patch.compat); + } else { + result.headers = patch.headers; + result.compat = patch.compat; + } + return enrichModelThinking(result); +} + +function applyModelOverride(model: Model, override: ModelOverride): Model { + return applyModelPatch(model, override as ModelPatch, "merge"); +} + +interface CustomModelDefinitionLike extends ModelPatch { + id: string; + api?: Api; + baseUrl?: string; + cost?: Model["cost"]; +} + interface CustomModelBuildOptions { useDefaults: boolean; } -type CustomModelOverlay = { +interface CustomModelOverlay extends ModelPatch { id: string; provider: string; api: Api; baseUrl: string; - name?: string; - reasoning?: boolean; - thinking?: ThinkingConfig; - input?: ("text" | "image")[]; - cost?: { input: number; output: number; cacheRead: number; cacheWrite: number }; - contextWindow?: number; - maxTokens?: number; - omitMaxOutputTokens?: boolean; - headers?: Record; - compat?: Model["compat"]; - contextPromotionTarget?: string; - premiumMultiplier?: number; + cost?: Model["cost"]; isOAuth?: boolean; -}; +} function mergeCustomModelHeaders( providerHeaders: Record | undefined, @@ -705,8 +433,8 @@ function buildCustomModelOverlay( baseUrl: modelDef.baseUrl ?? providerBaseUrl, name: modelDef.name, reasoning: modelDef.reasoning, - thinking: modelDef.thinking as ThinkingConfig | undefined, - input: modelDef.input as ("text" | "image")[] | undefined, + thinking: modelDef.thinking, + input: modelDef.input, cost: modelDef.cost, contextWindow: modelDef.contextWindow, maxTokens: modelDef.maxTokens, @@ -719,137 +447,6 @@ function buildCustomModelOverlay( }; } -// Custom provider entries often front a known upstream model through a local proxy. -// Use bundled metadata for missing pricing/capability fields, but keep the custom transport. -function shouldReplaceCustomReference(existing: Model | undefined, candidate: Model): boolean { - if (!existing) return true; - if (candidate.contextWindow !== existing.contextWindow) { - return candidate.contextWindow > existing.contextWindow; - } - if (candidate.maxTokens !== existing.maxTokens) { - return candidate.maxTokens > existing.maxTokens; - } - const existingHasCachePricing = existing.cost.cacheRead > 0 || existing.cost.cacheWrite > 0; - const candidateHasCachePricing = candidate.cost.cacheRead > 0 || candidate.cost.cacheWrite > 0; - if (candidateHasCachePricing !== existingHasCachePricing) { - return candidateHasCachePricing; - } - return existing.provider !== "openai" && candidate.provider === "openai"; -} - -function normalizeCustomReferenceKey(value: string): string { - return value.trim().toLowerCase(); -} - -function buildCustomReferenceMap(): Map> { - const references = new Map>(); - for (const provider of getBundledProviders()) { - for (const model of getBundledModels(provider as Parameters[0])) { - const candidate = model as Model; - const key = normalizeCustomReferenceKey(candidate.id); - if (shouldReplaceCustomReference(references.get(key), candidate)) { - references.set(key, candidate); - } - } - } - return references; -} - -function buildCustomReferenceSuffixAliasMap(exactReferences: ReadonlyMap>): Map> { - const aliases = new Map>(); - for (const reference of exactReferences.values()) { - const slashIndex = reference.id.lastIndexOf("/"); - if (slashIndex === -1) { - continue; - } - const suffix = reference.id.slice(slashIndex + 1); - const alias = getLongestModelLikeIdSegment(suffix); - if (!alias) { - continue; - } - if (shouldReplaceCustomReference(aliases.get(alias), reference)) { - aliases.set(alias, reference); - } - } - return aliases; -} - -// Lazy: building these maps walks every bundled model (~12K) and triggers -// model enrichment in pi-ai; defer off module load until the first -// custom-model reference lookup actually needs them. -let customReferenceMap: Map> | undefined; -let customReferenceSuffixAliasMap: Map> | undefined; - -function getCustomReferenceMaps(): { exact: Map>; suffixAlias: Map> } { - if (customReferenceMap === undefined || customReferenceSuffixAliasMap === undefined) { - customReferenceMap = buildCustomReferenceMap(); - customReferenceSuffixAliasMap = buildCustomReferenceSuffixAliasMap(customReferenceMap); - } - return { exact: customReferenceMap, suffixAlias: customReferenceSuffixAliasMap }; -} - -const CUSTOM_REFERENCE_TRAILING_MARKER_PATTERN = - /[-:](?:thinking|customtools|high|low|medium|minimal|xhigh|free|cloud|exacto|nitro|original|optimized|nvfp4|fp8|fp4|bf16|int8|int4|search)$/i; - -function stripCustomReferenceTrailingMarker(candidate: string): string | undefined { - const match = CUSTOM_REFERENCE_TRAILING_MARKER_PATTERN.exec(candidate); - return match ? candidate.slice(0, match.index) : undefined; -} - -function getCustomReferenceCandidateIds(modelId: string): string[] { - const candidates = new Set(); - const queue = [modelId]; - for (let index = 0; index < queue.length; index += 1) { - const candidate = queue[index]?.trim(); - if (!candidate || candidates.has(candidate)) continue; - candidates.add(candidate); - - for (const stripped of getBracketStrippedModelIdCandidates(candidate)) { - queue.push(stripped); - } - for (const segment of getModelLikeIdSegments(candidate)) { - queue.push(segment); - } - - for (const suffix of [":cloud", "-cloud"] as const) { - if (candidate.toLowerCase().endsWith(suffix)) { - queue.push(candidate.slice(0, -suffix.length)); - } - } - - const slashIndex = candidate.lastIndexOf("/"); - if (slashIndex !== -1) { - queue.push(candidate.slice(slashIndex + 1)); - } - - const colonToDash = candidate.replace(/:/g, "-"); - if (colonToDash !== candidate) { - queue.push(colonToDash); - } - - const lowercased = candidate.toLowerCase(); - if (lowercased !== candidate) { - queue.push(lowercased); - } - - const strippedMarker = stripCustomReferenceTrailingMarker(candidate); - if (strippedMarker) { - queue.push(strippedMarker); - } - } - return [...candidates]; -} - -function resolveCustomModelReference(modelId: string): Model | undefined { - const { exact, suffixAlias } = getCustomReferenceMaps(); - for (const candidate of getCustomReferenceCandidateIds(modelId)) { - const key = normalizeCustomReferenceKey(candidate); - const reference = exact.get(key) ?? suffixAlias.get(key); - if (reference) return reference; - } - return undefined; -} - function applyStandaloneCustomModelPolicies(model: CustomModelOverlay): CustomModelOverlay { if (model.id !== "gpt-5.4" || model.provider === "github-copilot" || model.contextWindow !== undefined) { return model; @@ -859,7 +456,9 @@ function applyStandaloneCustomModelPolicies(model: CustomModelOverlay): CustomMo function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuildOptions): Model { const resolvedModel = options.useDefaults ? applyStandaloneCustomModelPolicies(model) : model; - const reference = options.useDefaults ? resolveCustomModelReference(resolvedModel.id) : undefined; + const reference = options.useDefaults + ? resolveModelReference(resolvedModel.id, getBundledModelReferenceIndex()) + : undefined; const cost = resolvedModel.cost ?? reference?.cost ?? @@ -1154,75 +753,37 @@ export class ModelRegistry { } #mergeResolvedModels(baseModels: Model[], replacementModels: Model[]): Model[] { - const merged = [...baseModels]; - const indexByKey = new Map(); - for (let i = 0; i < merged.length; i += 1) { - const m = merged[i]; - indexByKey.set(`${m.provider}\u0000${m.id}`, i); - } - for (const replacementModel of replacementModels) { - const key = `${replacementModel.provider}\u0000${replacementModel.id}`; - const existingIndex = indexByKey.get(key); - if (existingIndex !== undefined) { - const existing = merged[existingIndex]; - merged[existingIndex] = { - ...replacementModel, - contextWindow: - replacementModel.contextWindow === UNK_CONTEXT_WINDOW - ? existing.contextWindow - : replacementModel.contextWindow, - maxTokens: - replacementModel.maxTokens === UNK_MAX_TOKENS ? existing.maxTokens : replacementModel.maxTokens, - }; - } else { - merged.push(replacementModel); - indexByKey.set(key, merged.length - 1); - } - } - return merged; + return mergeByModelKey(baseModels, replacementModels, (existing, replacementModel) => { + if (!existing) return replacementModel; + return { + ...replacementModel, + contextWindow: + replacementModel.contextWindow === UNK_CONTEXT_WINDOW + ? existing.contextWindow + : replacementModel.contextWindow, + maxTokens: replacementModel.maxTokens === UNK_MAX_TOKENS ? existing.maxTokens : replacementModel.maxTokens, + }; + }); } /** Merge custom models with built-in, replacing by provider+id match */ #mergeCustomModels(builtInModels: Model[], customModels: CustomModelOverlay[]): Model[] { - const merged = [...builtInModels]; - const indexByKey = new Map(); - for (let i = 0; i < merged.length; i += 1) { - const m = merged[i]; - indexByKey.set(`${m.provider}\u0000${m.id}`, i); - } - for (const customModel of customModels) { - const key = `${customModel.provider}\u0000${customModel.id}`; - const existingIndex = indexByKey.get(key); - if (existingIndex !== undefined) { - const existingModel = merged[existingIndex]; - merged[existingIndex] = enrichModelThinking({ + return mergeByModelKey(builtInModels, customModels, (existingModel, customModel) => { + if (!existingModel) return finalizeCustomModel(customModel, { useDefaults: true }); + // Same-id custom definitions replace bundled transport behavior, so the + // patch is applied with the `replace` transport policy. + return applyModelPatch( + { ...existingModel, id: customModel.id, provider: customModel.provider, api: customModel.api, baseUrl: customModel.baseUrl, - name: customModel.name ?? existingModel.name, - reasoning: customModel.reasoning ?? existingModel.reasoning, - thinking: customModel.thinking ?? existingModel.thinking, - input: customModel.input ?? existingModel.input, - cost: customModel.cost ?? existingModel.cost, - contextWindow: customModel.contextWindow ?? existingModel.contextWindow, - maxTokens: customModel.maxTokens ?? existingModel.maxTokens, - omitMaxOutputTokens: customModel.omitMaxOutputTokens ?? existingModel.omitMaxOutputTokens, - // Same-id custom definitions replace bundled transport behavior. Provider-level - // headers/compat were already folded into customModel during parsing; do not - // re-merge bundled transport metadata here. - headers: customModel.headers, - compat: customModel.compat, - contextPromotionTarget: customModel.contextPromotionTarget ?? existingModel.contextPromotionTarget, - premiumMultiplier: customModel.premiumMultiplier ?? existingModel.premiumMultiplier, - } as Model); - } else { - merged.push(finalizeCustomModel(customModel, { useDefaults: true })); - indexByKey.set(key, merged.length - 1); - } - } - return merged; + }, + customModel, + "replace", + ); + }); } #loadCachedStandardProviderModels(): { models: Model[]; authoritativeFreshProviders: Set } { @@ -1532,7 +1093,10 @@ export class ModelRegistry { let discoveryError: string | undefined; const fetchDynamicModels = async (): Promise[] | null> => { try { - const models = await this.#discoverModelsByProviderType(providerConfig); + const models = this.#applyProviderModelOverrides( + providerId, + await discoverModelsByProviderType(providerConfig, this.#discoveryContext()), + ); this.#lastDiscoveryWarnings.delete(providerId); return models; } catch (error) { @@ -1581,18 +1145,14 @@ export class ModelRegistry { ); } - #discoverModelsByProviderType(providerConfig: DiscoveryProviderConfig): Promise[]> { - switch (providerConfig.discovery.type) { - case "ollama": - return this.#discoverOllamaModels(providerConfig); - case "llama.cpp": - return this.#discoverLlamaCppModels(providerConfig); - case "lm-studio": - case "openai-models-list": - return this.#discoverOpenAIModelsList(providerConfig); - case "proxy": - return this.#discoverProxyModels(providerConfig); - } + #discoveryContext(): DiscoveryContext { + return { + fetch: this.#fetch, + getBearerApiKey: async provider => { + const apiKey = await this.authStorage.getApiKey(provider); + return apiKey && apiKey !== DEFAULT_LOCAL_TOKEN && apiKey !== kNoAuth ? apiKey : undefined; + }, + }; } #warnProviderDiscoveryFailure(providerConfig: DiscoveryProviderConfig, error: string): void { @@ -1744,361 +1304,6 @@ export class ModelRegistry { } } - async #discoverOllamaModelMetadata( - endpoint: string, - modelId: string, - headers: Record | undefined, - ): Promise { - const showUrl = `${endpoint}/api/show`; - try { - const response = await this.#fetch(showUrl, { - method: "POST", - headers: { ...(headers ?? {}), "Content-Type": "application/json" }, - body: JSON.stringify({ model: modelId }), - signal: AbortSignal.timeout(150), - }); - if (!response.ok) { - return null; - } - const payload = (await response.json()) as unknown; - if (!isRecord(payload)) { - return null; - } - const contextWindow = extractOllamaContextWindow(payload); - const capabilities = payload.capabilities; - if (Array.isArray(capabilities)) { - const normalized = new Set( - capabilities.flatMap(capability => (typeof capability === "string" ? [capability.toLowerCase()] : [])), - ); - const supportsVision = normalized.has("vision") || normalized.has("image"); - return { - reasoning: normalized.has("thinking"), - input: supportsVision ? ["text", "image"] : ["text"], - contextWindow, - }; - } - if (!isRecord(capabilities)) { - return { - reasoning: false, - input: ["text"], - contextWindow, - }; - } - const supportsVision = capabilities.vision === true || capabilities.image === true; - return { - reasoning: capabilities.thinking === true, - input: supportsVision ? ["text", "image"] : ["text"], - contextWindow, - }; - } catch { - return null; - } - } - - async #discoverOllamaModels(providerConfig: DiscoveryProviderConfig): Promise[]> { - const endpoint = this.#normalizeOllamaBaseUrl(providerConfig.baseUrl); - const tagsUrl = `${endpoint}/api/tags`; - const headers = { ...(providerConfig.headers ?? {}) }; - const response = await this.#fetch(tagsUrl, { - headers, - signal: AbortSignal.timeout(250), - }); - if (!response.ok) { - throw new Error(`HTTP ${response.status} from ${tagsUrl}`); - } - const payload = (await response.json()) as { models?: Array<{ name?: string; model?: string }> }; - const entries = (payload.models ?? []).flatMap(item => { - const id = item.model || item.name; - return id ? [{ id, name: item.name || id }] : []; - }); - const metadataById = new Map( - await Promise.all( - entries.map( - async entry => [entry.id, await this.#discoverOllamaModelMetadata(endpoint, entry.id, headers)] as const, - ), - ), - ); - const discovered = entries.map(entry => { - const metadata = metadataById.get(entry.id); - return enrichModelThinking({ - id: entry.id, - name: entry.name, - api: providerConfig.api, - provider: providerConfig.provider, - baseUrl: `${endpoint}/v1`, - reasoning: metadata?.reasoning ?? false, - input: metadata?.input ?? ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: metadata?.contextWindow ?? 128000, - maxTokens: Math.min(metadata?.contextWindow ?? Number.POSITIVE_INFINITY, DISCOVERY_DEFAULT_MAX_TOKENS), - headers: providerConfig.headers, - }); - }); - return this.#applyProviderModelOverrides(providerConfig.provider, discovered); - } - - async #discoverLlamaCppServerMetadata( - baseUrl: string, - headers: Record | undefined, - ): Promise { - const propsUrl = `${this.#toLlamaCppNativeBaseUrl(baseUrl)}/props`; - try { - const response = await this.#fetch(propsUrl, { - headers, - signal: AbortSignal.timeout(150), - }); - if (!response.ok) { - return null; - } - const payload = (await response.json()) as unknown; - if (!isRecord(payload)) { - return null; - } - return { - contextWindow: extractLlamaCppContextWindow(payload), - input: extractLlamaCppInputCapabilities(payload), - }; - } catch { - return null; - } - } - - async #discoverLlamaCppModels(providerConfig: DiscoveryProviderConfig): Promise[]> { - const baseUrl = this.#normalizeLlamaCppBaseUrl(providerConfig.baseUrl); - const modelsUrl = `${baseUrl}/models`; - - const headers: Record = { ...(providerConfig.headers ?? {}) }; - const apiKey = await this.authStorage.getApiKey(providerConfig.provider); - if (apiKey && apiKey !== DEFAULT_LOCAL_TOKEN && apiKey !== kNoAuth) { - headers.Authorization = `Bearer ${apiKey}`; - } - - const [response, serverMetadata] = await Promise.all([ - this.#fetch(modelsUrl, { - headers, - signal: AbortSignal.timeout(250), - }), - this.#discoverLlamaCppServerMetadata(baseUrl, headers), - ]); - if (!response.ok) { - throw new Error(`HTTP ${response.status} from ${modelsUrl}`); - } - const payload = (await response.json()) as { data?: Array<{ id: string }> }; - const models = payload.data ?? []; - const discovered: Model[] = []; - for (const item of models) { - const id = item.id; - if (!id) continue; - discovered.push( - enrichModelThinking({ - id, - name: id, - api: providerConfig.api, - provider: providerConfig.provider, - baseUrl, - reasoning: false, - input: serverMetadata?.input ?? ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: serverMetadata?.contextWindow ?? 128000, - maxTokens: Math.min( - serverMetadata?.contextWindow ?? Number.POSITIVE_INFINITY, - DISCOVERY_DEFAULT_MAX_TOKENS, - ), - headers, - compat: { - supportsStore: false, - supportsDeveloperRole: false, - supportsReasoningEffort: false, - }, - }), - ); - } - return this.#applyProviderModelOverrides(providerConfig.provider, discovered); - } - - async #discoverOpenAIModelsList(providerConfig: DiscoveryProviderConfig): Promise[]> { - const baseUrl = this.#normalizeOpenAIModelsListBaseUrl(providerConfig.baseUrl); - const modelsUrl = `${baseUrl}/models`; - - const headers: Record = { ...(providerConfig.headers ?? {}) }; - const apiKey = await this.authStorage.getApiKey(providerConfig.provider); - if (apiKey && apiKey !== DEFAULT_LOCAL_TOKEN && apiKey !== kNoAuth) { - headers.Authorization = `Bearer ${apiKey}`; - } - - const response = await this.#fetch(modelsUrl, { - headers, - signal: AbortSignal.timeout(10_000), - }); - if (!response.ok) { - throw new Error(`HTTP ${response.status} from ${modelsUrl}`); - } - const payload = (await response.json()) as { data?: Array<{ id: string }> }; - const models = payload.data ?? []; - const discovered: Model[] = []; - for (const item of models) { - const id = item.id; - if (!id) continue; - discovered.push( - enrichModelThinking({ - id, - name: id, - api: providerConfig.api, - provider: providerConfig.provider, - baseUrl, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128000, - maxTokens: discoveryDefaultMaxTokens(providerConfig.api), - headers, - compat: { - supportsStore: false, - supportsDeveloperRole: false, - supportsReasoningEffort: false, - }, - }), - ); - } - return this.#applyProviderModelOverrides(providerConfig.provider, discovered); - } - - /** - * Discover models from an Anthropic+OpenAI-compatible reseller proxy that - * exposes both `/v1/messages` and `/v1/chat/completions`, advertising each - * model's wire capabilities through `supported_endpoint_types` on - * `GET /v1/models` (new-api / one-api-style proxies). - * - * Routing per model: - * supported_endpoint_types: ["anthropic", ...] -> api: "anthropic-messages" - * supported_endpoint_types: ["openai"] -> api: "openai-completions" - * missing / neither -> provider-level api fallback - * - * Anthropic models share the same baseUrl; the Anthropic SDK strips a - * trailing `/v1` itself before appending `/v1/messages`, so the discovery - * URL (which ends in `/v1`) round-trips correctly. - */ - async #discoverProxyModels(providerConfig: DiscoveryProviderConfig): Promise[]> { - const baseUrl = this.#normalizeOpenAIModelsListBaseUrl(providerConfig.baseUrl); - const modelsUrl = `${baseUrl}/models`; - - const headers: Record = { ...(providerConfig.headers ?? {}) }; - const apiKey = await this.authStorage.getApiKey(providerConfig.provider); - if (apiKey && apiKey !== DEFAULT_LOCAL_TOKEN && apiKey !== kNoAuth) { - headers.Authorization = `Bearer ${apiKey}`; - } - - const response = await this.#fetch(modelsUrl, { - headers, - signal: AbortSignal.timeout(10_000), - }); - if (!response.ok) { - throw new Error(`HTTP ${response.status} from ${modelsUrl}`); - } - const payload = (await response.json()) as { - data?: Array<{ id?: string; name?: string; supported_endpoint_types?: string[] }>; - }; - const items = payload.data ?? []; - const discovered: Model[] = []; - for (const item of items) { - const id = item.id; - if (!id) continue; - const endpoints = item.supported_endpoint_types ?? []; - const api: Api | undefined = endpoints.includes("anthropic") - ? "anthropic-messages" - : endpoints.includes("openai") - ? "openai-completions" - : providerConfig.api; - if (!api) continue; - const isAnthropic = api === "anthropic-messages"; - const reference = resolveCustomModelReference(id); - const discoveryName = typeof item.name === "string" ? item.name.trim() : ""; - const displayName = - reference?.name ?? - (discoveryName && discoveryName !== id ? discoveryName : undefined) ?? - stripBracketedModelIdAffixes(id) ?? - id; - discovered.push( - enrichModelThinking({ - id, - name: displayName, - api, - provider: providerConfig.provider, - baseUrl, - reasoning: reference?.reasoning ?? false, - thinking: reference?.thinking, - input: reference?.input ?? ["text"], - // Proxy pricing is provider-specific and usually does not match - // upstream bundled catalogs, so keep costs local-unknown even when - // we successfully recover the upstream model identity. - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: reference?.contextWindow ?? 128000, - maxTokens: reference?.maxTokens ?? discoveryDefaultMaxTokens(api), - headers, - // OpenAI-compat fields are no-ops on anthropic models; the - // Anthropic SDK ignores them. Provider-level disableStrictTools - // flows in via #applyProviderCompat for the third-party-Anthropic - // path. Cross-wire bundled compat is intentionally not copied: - // request-shaping fields are provider-wire specific. - compat: isAnthropic - ? undefined - : { - supportsStore: false, - supportsDeveloperRole: false, - supportsReasoningEffort: false, - }, - }), - ); - } - return this.#applyProviderModelOverrides(providerConfig.provider, discovered); - } - - #normalizeLlamaCppBaseUrl(baseUrl?: string): string { - const defaultBaseUrl = "http://127.0.0.1:8080"; - const raw = baseUrl || defaultBaseUrl; - try { - const parsed = new URL(raw); - const trimmedPath = parsed.pathname.replace(/\/+$/g, ""); - return `${parsed.protocol}//${parsed.host}${trimmedPath}`; - } catch { - return raw; - } - } - - #toLlamaCppNativeBaseUrl(baseUrl: string): string { - try { - const parsed = new URL(baseUrl); - const trimmedPath = parsed.pathname.replace(/\/+$/g, ""); - parsed.pathname = trimmedPath.endsWith("/v1") ? trimmedPath.slice(0, -3) || "/" : trimmedPath || "/"; - const normalized = `${parsed.protocol}//${parsed.host}${parsed.pathname}`; - return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized; - } catch { - return baseUrl.endsWith("/v1") ? baseUrl.slice(0, -3) : baseUrl; - } - } - - #normalizeOpenAIModelsListBaseUrl(baseUrl?: string): string { - const defaultBaseUrl = "http://127.0.0.1:1234/v1"; - const raw = baseUrl || defaultBaseUrl; - try { - const parsed = new URL(raw); - const trimmedPath = parsed.pathname.replace(/\/+$/g, ""); - parsed.pathname = trimmedPath.endsWith("/v1") ? trimmedPath || "/v1" : `${trimmedPath}/v1`; - return `${parsed.protocol}//${parsed.host}${parsed.pathname}`; - } catch { - return raw; - } - } - #normalizeOllamaBaseUrl(baseUrl?: string): string { - const raw = baseUrl || DEFAULT_OLLAMA_BASE_URL; - try { - const parsed = new URL(raw); - return `${parsed.protocol}//${parsed.host}`; - } catch { - return DEFAULT_OLLAMA_BASE_URL; - } - } - #applyProviderModelOverrides(provider: string, models: Model[]): Model[] { const overrides = this.#modelOverrides.get(provider); if (!overrides || overrides.size === 0) return models; @@ -2176,7 +1381,11 @@ export class ModelRegistry { this.#rebuildPending = true; return; } - this.#canonicalIndex = buildCanonicalModelIndex(this.#models, this.#equivalenceConfig); + this.#canonicalIndex = buildCanonicalModelIndex( + this.#models, + getBundledCanonicalReferenceData(), + this.#equivalenceConfig, + ); this.#rebuildPending = false; } @@ -2190,7 +1399,11 @@ export class ModelRegistry { } if (this.#rebuildSuspended === 0 && this.#rebuildPending) { this.#rebuildPending = false; - this.#canonicalIndex = buildCanonicalModelIndex(this.#models, this.#equivalenceConfig); + this.#canonicalIndex = buildCanonicalModelIndex( + this.#models, + getBundledCanonicalReferenceData(), + this.#equivalenceConfig, + ); } } @@ -2290,53 +1503,11 @@ export class ModelRegistry { }); } - #buildModelOrder(candidates: readonly Model[]): Map { - const modelOrder = new Map(); - for (let index = 0; index < candidates.length; index += 1) { - modelOrder.set(formatCanonicalVariantSelector(candidates[index]!), index); - } - return modelOrder; - } - - #providerRank(): Map { - return buildModelProviderPriorityRank(getConfiguredProviderOrderFromSettings()); - } - - #resolveCanonicalVariant( - variants: readonly CanonicalModelVariant[], - modelOrder: ReadonlyMap, - providerRank: ReadonlyMap, - ): CanonicalModelVariant | undefined { - if (variants.length === 0) { - return undefined; - } - const sourceRank: Record = { - override: 1, - bundled: 1, - heuristic: 2, - fallback: 3, + #variantPreferences(candidates: readonly Model[]): CanonicalVariantPreferences { + return { + modelOrder: buildCanonicalModelOrder(candidates), + providerRank: buildModelProviderPriorityRank(getConfiguredProviderOrderFromSettings()), }; - return [...variants].sort((left, right) => { - const leftProviderRank = providerRank.get(left.model.provider.toLowerCase()) ?? Number.MAX_SAFE_INTEGER; - const rightProviderRank = providerRank.get(right.model.provider.toLowerCase()) ?? Number.MAX_SAFE_INTEGER; - if (leftProviderRank !== rightProviderRank) { - return leftProviderRank - rightProviderRank; - } - const leftExact = left.model.id === left.canonicalId ? 0 : 1; - const rightExact = right.model.id === right.canonicalId ? 0 : 1; - if (leftExact !== rightExact) { - return leftExact - rightExact; - } - if (sourceRank[left.source] !== sourceRank[right.source]) { - return sourceRank[left.source] - sourceRank[right.source]; - } - if (left.model.id.length !== right.model.id.length) { - return left.model.id.length - right.model.id.length; - } - const leftOrder = modelOrder.get(left.selector) ?? Number.MAX_SAFE_INTEGER; - const rightOrder = modelOrder.get(right.selector) ?? Number.MAX_SAFE_INTEGER; - return leftOrder - rightOrder; - })[0]; } getCanonicalModels(options?: CanonicalModelQueryOptions): CanonicalModelRecord[] { @@ -2366,15 +1537,14 @@ export class ModelRegistry { getCanonicalModelSelections(options?: CanonicalModelQueryOptions): CanonicalModelSelection[] { const { candidateKeys, isAvailable } = this.#canonicalQueryFilters(options); const candidates = options?.candidates ?? (options?.availableOnly ? this.getAvailable() : this.getAll()); - const modelOrder = this.#buildModelOrder(candidates); - const providerRank = this.#providerRank(); + const preferences = this.#variantPreferences(candidates); const selections: CanonicalModelSelection[] = []; for (const record of this.#canonicalIndex.records) { const variants = this.#filterCanonicalVariants(record, candidateKeys, isAvailable); if (variants.length === 0) { continue; } - const resolved = this.#resolveCanonicalVariant(variants, modelOrder, providerRank); + const resolved = resolveCanonicalVariant(variants, preferences); if (!resolved) { continue; } @@ -2401,7 +1571,7 @@ export class ModelRegistry { return undefined; } const candidates = options?.candidates ?? (options?.availableOnly ? this.getAvailable() : this.getAll()); - return this.#resolveCanonicalVariant(variants, this.#buildModelOrder(candidates), this.#providerRank())?.model; + return resolveCanonicalVariant(variants, this.#variantPreferences(candidates))?.model; } getCanonicalId(model: Model): string | undefined { diff --git a/packages/coding-agent/src/config/model-resolver.ts b/packages/coding-agent/src/config/model-resolver.ts index df3fbd311..27bca36c9 100644 --- a/packages/coding-agent/src/config/model-resolver.ts +++ b/packages/coding-agent/src/config/model-resolver.ts @@ -1,28 +1,46 @@ /** - * Model resolution, scoping, and initial selection + * Model resolution, scoping, and initial selection. + * + * Layering: + * - `matchModel` is the single matching engine. Order: exact `provider/id` + * reference (with OpenRouter routed/date fallbacks) → exact canonical id → + * exact bare id → provider-scoped fuzzy → substring with alias-vs-dated pick. + * - `parseModelPatternWithContext`/`parseModelPattern` layer the selector + * grammar on top: trailing `:level` thinking suffixes (`splitThinkingSuffix`) + * and `@upstream` provider routing (`splitUpstreamRouting`). + * - Everything else (`resolveModelFromString`, `resolveModelOverride*`, + * `resolveRoleSelection`, `resolveModelScope`, `resolveCliModel`, + * `findSmolModel`/`findSlowModel`) adapts inputs — roles, settings patterns, + * CLI flags, scope globs — onto that pipeline. */ import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; -import { - type Api, - clampThinkingLevelForModel, - DEFAULT_MODEL_PER_PROVIDER, - type Effort, - type KnownProvider, - type Model, - modelsAreEqual, -} from "@oh-my-pi/pi-ai"; +import type { Api, Effort, KnownProvider, Model } from "@oh-my-pi/pi-ai"; +import { buildModelProviderPriorityRank } from "@oh-my-pi/pi-catalog/identity"; +import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking"; +import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models"; +import { DEFAULT_MODEL_PER_PROVIDER } from "@oh-my-pi/pi-catalog/provider-models"; import { fuzzyMatch } from "@oh-my-pi/pi-tui"; import { logger } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; import MODEL_PRIO from "../priority.json" with { type: "json" }; import { parseThinkingLevel, resolveThinkingLevelForModel } from "../thinking"; -import { buildModelProviderPriorityRank } from "./model-provider-priority"; -import { isAuthenticated, kNoAuth, MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "./model-registry"; +import { isAuthenticated, kNoAuth, type ModelRegistry } from "./model-registry"; +import { MODEL_ROLE_IDS, type ModelRole } from "./model-roles"; import type { Settings } from "./settings"; -/** Default model IDs for each known provider */ -export const defaultModelPerProvider: Record = DEFAULT_MODEL_PER_PROVIDER; +/** + * Pick the first available model matching a known provider's default id + * (catalog table order), falling back to the first available model. + */ +function pickDefaultAvailableModel(availableModels: Model[]): Model | undefined { + for (const provider of Object.keys(DEFAULT_MODEL_PER_PROVIDER) as KnownProvider[]) { + const defaultId = DEFAULT_MODEL_PER_PROVIDER[provider]; + const match = availableModels.find(m => m.provider === provider && m.id === defaultId); + if (match) return match; + } + return availableModels[0]; +} export interface ScopedModel { model: Model; @@ -30,6 +48,22 @@ export interface ScopedModel { explicitThinkingLevel: boolean; } +/** + * Split a trailing `:` thinking selector off a model pattern. + * + * `level` is set only when the suffix parses as a valid thinking level, in + * which case `base` has the suffix stripped; otherwise `base` is the input. + * `minColonIndex` requires the colon to appear strictly after that index — + * role-alias callers pass `PREFIX_MODEL_ROLE.length` so the base is at least + * as long as the `pi/` prefix. + */ +function splitThinkingSuffix(pattern: string, minColonIndex = -1): { base: string; level?: ThinkingLevel } { + const colonIdx = pattern.lastIndexOf(":"); + if (colonIdx <= minColonIndex) return { base: pattern }; + const level = parseThinkingLevel(pattern.slice(colonIdx + 1)); + return level ? { base: pattern.slice(0, colonIdx), level } : { base: pattern }; +} + /** * Parse a model string in "provider/modelId" format. * Returns undefined if the format is invalid. @@ -42,15 +76,8 @@ export function parseModelString( const id = modelStr.slice(slashIdx + 1); const provider = modelStr.slice(0, slashIdx); // Strip valid thinking level suffix (e.g., "claude-sonnet-4-6:high" -> id "claude-sonnet-4-6", thinkingLevel "high") - const colonIdx = id.lastIndexOf(":"); - if (colonIdx !== -1) { - const suffix = id.slice(colonIdx + 1); - const thinkingLevel = parseThinkingLevel(suffix); - if (thinkingLevel) { - return { provider, id: id.slice(0, colonIdx), thinkingLevel }; - } - } - return { provider, id }; + const { base, level } = splitThinkingSuffix(id); + return level ? { provider, id: base, thinkingLevel: level } : { provider, id }; } /** @@ -339,10 +366,7 @@ function isAlias(id: string): boolean { * Find an exact explicit provider/model match. * Bare model ids are handled separately so canonical ids can coalesce variants. */ -export function findExactModelReferenceMatch( - modelReference: string, - availableModels: Model[], -): Model | undefined { +function findExactModelReferenceMatch(modelReference: string, availableModels: Model[]): Model | undefined { const trimmedReference = modelReference.trim(); if (!trimmedReference) { return undefined; @@ -378,10 +402,15 @@ function findExactCanonicalModelMatch( } /** - * Try to match a pattern to a model from the available models list. + * The single model-matching engine. Tries, in order: + * 1. exact `provider/id` reference (OpenRouter routed/date fallbacks included), + * 2. exact canonical id (coalesces provider variants), + * 3. exact bare id (preference-ranked), + * 4. provider-scoped fuzzy match, + * 5. substring match with the alias-vs-dated pick. * Returns the matched model or undefined if no match found. */ -function tryMatchModel( +function matchModel( modelPattern: string, availableModels: Model[], context: ModelPreferenceContext, @@ -505,31 +534,21 @@ function parseModelPatternWithContext( options?: { allowInvalidThinkingSelectorFallback?: boolean; modelRegistry?: CanonicalModelRegistry }, ): ParsedModelResult { // Try exact match first - const exactMatch = tryMatchModel(pattern, availableModels, context, options); + const exactMatch = matchModel(pattern, availableModels, context, options); if (exactMatch) { return { model: exactMatch, thinkingLevel: undefined, warning: undefined, explicitThinkingLevel: false }; } - // No match - try splitting on last colon if present - const lastColonIndex = pattern.lastIndexOf(":"); - if (lastColonIndex === -1) { - // No colons, pattern simply doesn't match any model - return { model: undefined, thinkingLevel: undefined, warning: undefined, explicitThinkingLevel: false }; - } - - const prefix = pattern.substring(0, lastColonIndex); - const suffix = pattern.substring(lastColonIndex + 1); - - const parsedThinkingLevel = parseThinkingLevel(suffix); - if (parsedThinkingLevel) { - // Valid thinking level - recurse on prefix and use this level - const result = parseModelPatternWithContext(prefix, availableModels, context, options); + // No match - try stripping a valid thinking suffix and recursing + const { base, level } = splitThinkingSuffix(pattern); + if (level) { + const result = parseModelPatternWithContext(base, availableModels, context, options); if (result.model) { // Only use this thinking level if no warning from inner recursion const explicitThinkingLevel = !result.warning; return { model: result.model, - thinkingLevel: explicitThinkingLevel ? parsedThinkingLevel : undefined, + thinkingLevel: explicitThinkingLevel ? level : undefined, warning: result.warning, explicitThinkingLevel, }; @@ -537,6 +556,14 @@ function parseModelPatternWithContext( return result; } + const lastColonIndex = pattern.lastIndexOf(":"); + if (lastColonIndex === -1) { + // No colons, pattern simply doesn't match any model + return { model: undefined, thinkingLevel: undefined, warning: undefined, explicitThinkingLevel: false }; + } + const prefix = pattern.substring(0, lastColonIndex); + const suffix = pattern.substring(lastColonIndex + 1); + const allowFallback = options?.allowInvalidThinkingSelectorFallback ?? true; if (!allowFallback) { return { model: undefined, thinkingLevel: undefined, warning: undefined, explicitThinkingLevel: false }; @@ -606,10 +633,7 @@ function resolveConfiguredRolePattern(value: string, settings?: Settings): strin const normalized = value.trim(); if (!normalized) return undefined; - const lastColonIndex = normalized.lastIndexOf(":"); - const thinkingLevel = - lastColonIndex > PREFIX_MODEL_ROLE.length ? parseThinkingLevel(normalized.slice(lastColonIndex + 1)) : undefined; - const aliasCandidate = thinkingLevel ? normalized.slice(0, lastColonIndex) : normalized; + const { base: aliasCandidate, level: thinkingLevel } = splitThinkingSuffix(normalized, PREFIX_MODEL_ROLE.length); const role = getModelRoleAlias(aliasCandidate); if (!role) return [normalized]; @@ -736,9 +760,7 @@ export function extractExplicitThinkingSelector( let current = normalized; while (!visited.has(current)) { visited.add(current); - const lastColonIndex = current.lastIndexOf(":"); - const thinkingSelector = - lastColonIndex > PREFIX_MODEL_ROLE.length ? parseThinkingLevel(current.slice(lastColonIndex + 1)) : undefined; + const thinkingSelector = splitThinkingSuffix(current, PREFIX_MODEL_ROLE.length).level; if (thinkingSelector) { return thinkingSelector; } @@ -903,20 +925,8 @@ function resolveExactCanonicalScopePattern( modelRegistry: Pick, availableModels: Model[], ): { models: Model[]; thinkingLevel?: ThinkingLevel; explicitThinkingLevel: boolean } | undefined { - const lastColonIndex = pattern.lastIndexOf(":"); - let canonicalId = pattern; - let thinkingLevel: ThinkingLevel | undefined; - let explicitThinkingLevel = false; - - if (lastColonIndex !== -1) { - const suffix = pattern.substring(lastColonIndex + 1); - const parsedThinkingLevel = parseThinkingLevel(suffix); - if (parsedThinkingLevel) { - canonicalId = pattern.substring(0, lastColonIndex); - thinkingLevel = parsedThinkingLevel; - explicitThinkingLevel = true; - } - } + const { base: canonicalId, level: thinkingLevel } = splitThinkingSuffix(pattern); + const explicitThinkingLevel = thinkingLevel !== undefined; const variants = modelRegistry .getCanonicalVariants(canonicalId, { availableOnly: true, candidates: availableModels }) @@ -947,25 +957,23 @@ export async function resolveModelScope( const availableModels = modelRegistry.getAvailable(); const context = buildPreferenceContext(availableModels, preferences); const scopedModels: ScopedModel[] = []; + const addScopedModel = (model: Model, thinkingLevel: ThinkingLevel | undefined, explicit: boolean) => { + if (scopedModels.some(sm => modelsAreEqual(sm.model, model))) return; + scopedModels.push({ + model, + thinkingLevel: explicit + ? (resolveThinkingLevelForModel(model, thinkingLevel) ?? thinkingLevel) + : thinkingLevel, + explicitThinkingLevel: explicit, + }); + }; for (const pattern of patterns) { // Check if pattern contains glob characters if (pattern.includes("*") || pattern.includes("?") || pattern.includes("[")) { // Extract optional thinking level suffix (e.g., "provider/*:high") - const colonIdx = pattern.lastIndexOf(":"); - let globPattern = pattern; - let thinkingLevel: ThinkingLevel | undefined; - let explicitThinkingLevel = false; - - if (colonIdx !== -1) { - const suffix = pattern.substring(colonIdx + 1); - const parsedThinkingLevel = parseThinkingLevel(suffix); - if (parsedThinkingLevel) { - thinkingLevel = parsedThinkingLevel; - explicitThinkingLevel = true; - globPattern = pattern.substring(0, colonIdx); - } - } + const { base: globPattern, level: thinkingLevel } = splitThinkingSuffix(pattern); + const explicitThinkingLevel = thinkingLevel !== undefined; // Match against "provider/modelId" format OR just model ID // This allows "*sonnet*" to match without requiring "anthropic/*sonnet*" @@ -981,15 +989,7 @@ export async function resolveModelScope( } for (const model of matchingModels) { - if (!scopedModels.find(sm => modelsAreEqual(sm.model, model))) { - scopedModels.push({ - model, - thinkingLevel: explicitThinkingLevel - ? (resolveThinkingLevelForModel(model, thinkingLevel) ?? thinkingLevel) - : thinkingLevel, - explicitThinkingLevel, - }); - } + addScopedModel(model, thinkingLevel, explicitThinkingLevel); } continue; } @@ -997,16 +997,7 @@ export async function resolveModelScope( const exactCanonical = resolveExactCanonicalScopePattern(pattern, modelRegistry, availableModels); if (exactCanonical) { for (const model of exactCanonical.models) { - if (!scopedModels.find(sm => modelsAreEqual(sm.model, model))) { - scopedModels.push({ - model, - thinkingLevel: exactCanonical.explicitThinkingLevel - ? (resolveThinkingLevelForModel(model, exactCanonical.thinkingLevel) ?? - exactCanonical.thinkingLevel) - : exactCanonical.thinkingLevel, - explicitThinkingLevel: exactCanonical.explicitThinkingLevel, - }); - } + addScopedModel(model, exactCanonical.thinkingLevel, exactCanonical.explicitThinkingLevel); } continue; } @@ -1027,16 +1018,7 @@ export async function resolveModelScope( continue; } - // Avoid duplicates - if (!scopedModels.find(sm => modelsAreEqual(sm.model, model))) { - scopedModels.push({ - model, - thinkingLevel: explicitThinkingLevel - ? (resolveThinkingLevelForModel(model, thinkingLevel) ?? thinkingLevel) - : thinkingLevel, - explicitThinkingLevel, - }); - } + addScopedModel(model, thinkingLevel, explicitThinkingLevel); } return scopedModels; @@ -1127,14 +1109,11 @@ export function resolveCliModel(options: { // provider+id match over flat id match. Without this, a model with id // "zai/glm-5" on provider "vercel-ai-gateway" wins over provider "zai" // with id "glm-5", because Array.find returns the first catalog hit. - const slashIdx = lower.indexOf("/"); - let exact: (typeof availableModels)[number] | undefined; - if (slashIdx !== -1) { - const prefix = lower.substring(0, slashIdx); - const suffix = trimmedModel.substring(slashIdx + 1); - exact = resolveProviderModelReference(prefix, suffix, availableModels); - } + let exact = findExactModelReferenceMatch(trimmedModel, availableModels); if (!exact && !trimmedModel.includes(":")) { + // CLI flags address the full catalog, so unlike the engine's canonical + // step this lookup is unrestricted; the `:`-guard defers suffixed + // selectors (thinking levels, ollama-style ids) to the grammar below. const canonicalMatch = modelRegistry.resolveCanonicalModel?.(trimmedModel, { availableOnly: false }); if (canonicalMatch) { return { @@ -1147,6 +1126,8 @@ export function resolveCliModel(options: { } } if (!exact) { + // Flat exact id (or full selector) by catalog order: CLI resolution + // stays deterministic across runs regardless of usage-based ranking. exact = availableModels.find( model => model.id.toLowerCase() === lower || `${model.provider}/${model.id}`.toLowerCase() === lower, ); @@ -1213,11 +1194,7 @@ export function resolveCliModel(options: { let selector = provider ? formatModelString(model) : undefined; if (!provider) { - const lastColonIndex = pattern.lastIndexOf(":"); - const canonicalCandidate = - lastColonIndex !== -1 && parseThinkingLevel(pattern.substring(lastColonIndex + 1)) - ? pattern.substring(0, lastColonIndex) - : pattern; + const canonicalCandidate = splitThinkingSuffix(pattern).base; if (!canonicalCandidate.includes("/")) { const canonicalResolved = modelRegistry.resolveCanonicalModel?.(canonicalCandidate, { availableOnly: false }); if (canonicalResolved && canonicalResolved.provider === model.provider && canonicalResolved.id === model.id) { @@ -1316,18 +1293,9 @@ export async function findInitialModel(options: { // 4. Try first available model with valid API key const availableModels = modelRegistry.getAvailable(); - if (availableModels.length > 0) { - // Try to find a default model from known providers - for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) { - const defaultId = defaultModelPerProvider[provider]; - const match = availableModels.find(m => m.provider === provider && m.id === defaultId); - if (match) { - return { model: match, thinkingLevel: undefined, fallbackMessage: undefined }; - } - } - - // If no default found, use first available - return { model: availableModels[0], thinkingLevel: undefined, fallbackMessage: undefined }; + const fallback = pickDefaultAvailableModel(availableModels); + if (fallback) { + return { model: fallback, thinkingLevel: undefined, fallbackMessage: undefined }; } // 5. No model found @@ -1377,23 +1345,8 @@ export async function restoreModelFromSession( // Try to find any available model const availableModels = modelRegistry.getAvailable(); - if (availableModels.length > 0) { - // Try to find a default model from known providers - let fallbackModel: Model | undefined; - for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) { - const defaultId = defaultModelPerProvider[provider]; - const match = availableModels.find(m => m.provider === provider && m.id === defaultId); - if (match) { - fallbackModel = match; - break; - } - } - - // If no default found, use first available - if (!fallbackModel) { - fallbackModel = availableModels[0]; - } - + const fallbackModel = pickDefaultAvailableModel(availableModels); + if (fallbackModel) { if (shouldPrintMessages) { console.log(chalk.dim(`Falling back to: ${fallbackModel.provider}/${fallbackModel.id}`)); } diff --git a/packages/coding-agent/src/config/model-roles.ts b/packages/coding-agent/src/config/model-roles.ts new file mode 100644 index 000000000..c154e384b --- /dev/null +++ b/packages/coding-agent/src/config/model-roles.ts @@ -0,0 +1,74 @@ +/** + * Built-in model roles and role metadata helpers. + */ + +import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme"; +import type { Settings } from "./settings"; + +export type ModelRole = "default" | "smol" | "slow" | "vision" | "plan" | "designer" | "commit" | "task"; + +export interface ModelRoleInfo { + tag?: string; + name: string; + color?: ThemeColor; +} + +export const MODEL_ROLES: Record = { + default: { tag: "DEFAULT", name: "Default", color: "success" }, + smol: { tag: "SMOL", name: "Fast", color: "warning" }, + slow: { tag: "SLOW", name: "Thinking", color: "accent" }, + vision: { tag: "VISION", name: "Vision", color: "error" }, + plan: { tag: "PLAN", name: "Architect", color: "muted" }, + designer: { tag: "DESIGNER", name: "Designer", color: "muted" }, + commit: { tag: "COMMIT", name: "Commit", color: "dim" }, + task: { tag: "TASK", name: "Subtask", color: "muted" }, +}; + +export const MODEL_ROLE_IDS: ModelRole[] = ["default", "smol", "slow", "vision", "plan", "designer", "commit", "task"]; + +/** Alias for ModelRoleInfo - used for both built-in and custom roles */ +export type RoleInfo = ModelRoleInfo; + +/** + * Return the canonical set of known roles for selector/carousel UI. + * + * Built-ins always come first. Configured cycle order, model assignments, and + * tag metadata can introduce additional custom roles without requiring duplicate + * entries across settings. + */ +export function getKnownRoleIds(settings: Settings): string[] { + const roles = [...MODEL_ROLE_IDS] as string[]; + const seen = new Set(roles); + const addRole = (role: string) => { + if (seen.has(role)) return; + seen.add(role); + roles.push(role); + }; + + for (const role of settings.get("cycleOrder")) addRole(role); + for (const role in settings.getModelRoles()) addRole(role); + for (const role in settings.get("modelTags")) addRole(role); + + return roles; +} + +/** + * Get role info for a role name (built-in or custom). + * Configured metadata overrides built-in defaults when present. + */ +export function getRoleInfo(role: string, settings: Settings): RoleInfo { + const builtIn = role in MODEL_ROLES ? MODEL_ROLES[role as ModelRole] : undefined; + const configured = settings.get("modelTags")[role]; + + if (configured) { + return { + tag: builtIn?.tag, + name: configured.name || builtIn?.name || role, + color: configured.color && isValidThemeColor(configured.color) ? configured.color : builtIn?.color, + }; + } + + if (builtIn) return builtIn; + + return { name: role, color: "muted" }; +} diff --git a/packages/coding-agent/src/config/models-config.ts b/packages/coding-agent/src/config/models-config.ts new file mode 100644 index 000000000..198d79815 --- /dev/null +++ b/packages/coding-agent/src/config/models-config.ts @@ -0,0 +1,129 @@ +/** + * models.json config file handle and provider configuration validation. + */ + +import type { Api, Model } from "@oh-my-pi/pi-ai/types"; +import { ConfigFile } from "./config-file"; +import { + type ModelsConfig, + ModelsConfigSchema, + type ProviderAuthMode, + type ProviderDiscovery, +} from "./models-config-schema"; + +export type ProviderValidationMode = "models-config" | "runtime-register"; + +export interface ProviderValidationModel { + id: string; + api?: Api; + contextWindow?: number; + maxTokens?: number; +} + +export interface ProviderValidationConfig { + baseUrl?: string; + headers?: Record; + apiKey?: string; + api?: Api; + auth?: ProviderAuthMode; + oauthConfigured?: boolean; + discovery?: ProviderDiscovery; + compat?: Model["compat"]; + disableStrictTools?: boolean; + modelOverrides?: Record; + models: ProviderValidationModel[]; +} + +export function validateProviderConfiguration( + providerName: string, + config: ProviderValidationConfig, + mode: ProviderValidationMode, +): void { + const hasProviderApi = !!config.api; + const models = config.models; + + if (models.length === 0) { + if (mode === "models-config") { + const hasModelOverrides = config.modelOverrides && Object.keys(config.modelOverrides).length > 0; + if ( + !config.baseUrl && + !config.headers && + !config.compat && + !config.apiKey && + !config.disableStrictTools && + !hasModelOverrides && + !config.discovery + ) { + throw new Error( + `Provider ${providerName}: must specify "baseUrl", "headers", "apiKey", "compat", "disableStrictTools", "modelOverrides", "discovery", or "models"`, + ); + } + } + } else { + if (!config.baseUrl) { + throw new Error(`Provider ${providerName}: "baseUrl" is required when defining custom models.`); + } + const requiresAuth = + mode === "runtime-register" + ? !config.apiKey && !config.oauthConfigured + : !config.apiKey && (config.auth ?? "apiKey") !== "none"; + if (requiresAuth) { + throw new Error( + mode === "runtime-register" + ? `Provider ${providerName}: "apiKey" or "oauth" is required when defining models.` + : `Provider ${providerName}: "apiKey" is required when defining custom models unless auth is "none".`, + ); + } + } + + if (mode === "models-config" && config.discovery && !config.api && config.discovery.type !== "proxy") { + throw new Error(`Provider ${providerName}: "api" is required when discovery is enabled at provider level.`); + } + + for (const modelDef of models) { + if (!hasProviderApi && !modelDef.api) { + throw new Error( + mode === "runtime-register" + ? `Provider ${providerName}, model ${modelDef.id}: no "api" specified.` + : `Provider ${providerName}, model ${modelDef.id}: no "api" specified. Set at provider or model level.`, + ); + } + if (!modelDef.id) { + throw new Error(`Provider ${providerName}: model missing "id"`); + } + if (mode === "models-config") { + if (modelDef.contextWindow !== undefined && modelDef.contextWindow <= 0) { + throw new Error(`Provider ${providerName}, model ${modelDef.id}: invalid contextWindow`); + } + if (modelDef.maxTokens !== undefined && modelDef.maxTokens <= 0) { + throw new Error(`Provider ${providerName}, model ${modelDef.id}: invalid maxTokens`); + } + } + } +} + +export const ModelsConfigFile = new ConfigFile("models", ModelsConfigSchema).withValidation( + "models", + config => { + const providers = config.providers ?? {}; + for (const providerName in providers) { + const providerConfig = providers[providerName]; + validateProviderConfiguration( + providerName, + { + baseUrl: providerConfig.baseUrl, + headers: providerConfig.headers, + apiKey: providerConfig.apiKey, + api: providerConfig.api as Api | undefined, + auth: (providerConfig.auth ?? "apiKey") as ProviderAuthMode, + discovery: providerConfig.discovery as ProviderDiscovery | undefined, + compat: providerConfig.compat, + disableStrictTools: providerConfig.disableStrictTools, + modelOverrides: providerConfig.modelOverrides, + models: (providerConfig.models ?? []) as ProviderValidationModel[], + }, + "models-config", + ); + } + }, +); diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index a5ac01950..01d07e000 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -26,7 +26,7 @@ import { } from "@oh-my-pi/pi-utils"; import { YAML } from "bun"; import { type Settings as SettingsCapabilityItem, settingsCapability } from "../capability/settings"; -import type { ModelRole } from "../config/model-registry"; +import type { ModelRole } from "../config/model-roles"; import { loadCapability } from "../discovery"; import { isLightTheme, setAutoThemeMapping, setColorBlindMode, setSymbolPreset } from "../modes/theme/theme"; import { AgentStorage } from "../session/agent-storage"; diff --git a/packages/coding-agent/src/eval/completion-bridge.ts b/packages/coding-agent/src/eval/completion-bridge.ts index 848ca8504..bfa65ff05 100644 --- a/packages/coding-agent/src/eval/completion-bridge.ts +++ b/packages/coding-agent/src/eval/completion-bridge.ts @@ -12,7 +12,8 @@ * in, text (or, with `schema`, a structured object) out. */ import { instrumentedCompleteSimple, resolveTelemetry } from "@oh-my-pi/pi-agent-core"; -import { type Api, Effort, getSupportedEfforts, type Model, type Tool } from "@oh-my-pi/pi-ai"; +import { type Api, Effort, type Model, type Tool } from "@oh-my-pi/pi-ai"; +import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking"; import * as z from "zod/v4"; import { extractTextContent, extractToolCall, parseJsonPayload } from "../commit/utils"; diff --git a/packages/coding-agent/src/lib/xai-http.ts b/packages/coding-agent/src/lib/xai-http.ts index 78e2bf750..f7623edc0 100644 --- a/packages/coding-agent/src/lib/xai-http.ts +++ b/packages/coding-agent/src/lib/xai-http.ts @@ -1,6 +1,6 @@ // Ported from NousResearch/hermes-agent (MIT) — tools/xai_http.py. -import { getBundledModels } from "@oh-my-pi/pi-ai"; +import { getBundledModels } from "@oh-my-pi/pi-catalog/models"; import { $env } from "@oh-my-pi/pi-utils"; import type { ModelRegistry } from "../config/model-registry"; diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index ceac61ec4..772aabbc8 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -29,7 +29,7 @@ import { runListModelsCommand } from "./cli/list-models"; import { selectSession } from "./cli/session-picker"; import { applyStartupCwd } from "./cli/startup-cwd"; import { findConfigFile } from "./config"; -import { ModelRegistry, ModelsConfigFile } from "./config/model-registry"; +import { ModelRegistry } from "./config/model-registry"; import { getModelMatchPreferences, resolveCliModel, @@ -37,6 +37,7 @@ import { resolveModelScope, type ScopedModel, } from "./config/model-resolver"; +import { ModelsConfigFile } from "./config/models-config"; import { getDefault, type SettingPath, Settings, settings } from "./config/settings"; import { initializeWithSettings } from "./discovery"; import { diff --git a/packages/coding-agent/src/memories/index.ts b/packages/coding-agent/src/memories/index.ts index acd9d8c78..ae9d2fbbc 100644 --- a/packages/coding-agent/src/memories/index.ts +++ b/packages/coding-agent/src/memories/index.ts @@ -3,7 +3,8 @@ import type * as fsNode from "node:fs"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; -import { type ApiKey, clampThinkingLevelForModel, completeSimple, Effort, type Model } from "@oh-my-pi/pi-ai"; +import { type ApiKey, completeSimple, Effort, type Model } from "@oh-my-pi/pi-ai"; +import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking"; import { getAgentDbPath, getMemoriesDir, logger, parseJsonlLenient, prompt } from "@oh-my-pi/pi-utils"; import type { ModelRegistry } from "../config/model-registry"; diff --git a/packages/coding-agent/src/modes/components/model-selector.ts b/packages/coding-agent/src/modes/components/model-selector.ts index 5efd62877..277e9bcba 100644 --- a/packages/coding-agent/src/modes/components/model-selector.ts +++ b/packages/coding-agent/src/modes/components/model-selector.ts @@ -1,5 +1,7 @@ import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; -import { getSupportedEfforts, type Model, modelsAreEqual } from "@oh-my-pi/pi-ai"; +import type { Model } from "@oh-my-pi/pi-ai"; +import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking"; +import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models"; import { Container, fuzzyFilter, @@ -16,8 +18,8 @@ import { } from "@oh-my-pi/pi-tui"; import { formatNumber } from "@oh-my-pi/pi-utils"; import type { ModelRegistry } from "../../config/model-registry"; -import { getKnownRoleIds, getRoleInfo, MODEL_ROLE_IDS, MODEL_ROLES } from "../../config/model-registry"; import { getModelMatchPreferences, resolveModelRoleValue } from "../../config/model-resolver"; +import { getKnownRoleIds, getRoleInfo, MODEL_ROLE_IDS, MODEL_ROLES } from "../../config/model-roles"; import type { Settings } from "../../config/settings"; import { type ThemeColor, theme } from "../../modes/theme/theme"; import { matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers"; diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 711b24c22..b363d4db2 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs/promises"; import type { ImageContent } from "@oh-my-pi/pi-ai"; import type { AutocompleteProvider, SlashCommand } from "@oh-my-pi/pi-tui"; import { $env, logger, sanitizeText } from "@oh-my-pi/pi-utils"; -import { getRoleInfo } from "../../config/model-registry"; +import { getRoleInfo } from "../../config/model-roles"; import { isSettingsInitialized, settings } from "../../config/settings"; import { renderSegmentTrack } from "../../modes/components/segment-track"; import { TinyTitleDownloadProgressComponent } from "../../modes/components/tiny-title-download-progress"; diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 3ab9d7a45..d8dd97a5b 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -5,8 +5,8 @@ import type { OAuthProvider } from "@oh-my-pi/pi-ai/oauth/types"; import type { Component, OverlayHandle } from "@oh-my-pi/pi-tui"; import { Input, Loader, Spacer, Text } from "@oh-my-pi/pi-tui"; import { getAgentDbPath, getProjectDir, normalizePathForComparison } from "@oh-my-pi/pi-utils"; -import { getRoleInfo } from "../../config/model-registry"; import { formatModelSelectorValue } from "../../config/model-resolver"; +import { getRoleInfo } from "../../config/model-roles"; import { settings } from "../../config/settings"; import { disableProvider, enableProvider } from "../../discovery"; import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers"; diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 9bfda6c1d..b2dc31ba3 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -12,14 +12,8 @@ import { ThinkingLevel, } from "@oh-my-pi/pi-agent-core"; import type { CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction"; -import { - type AssistantMessage, - type ImageContent, - type Message, - type Model, - modelsAreEqual, - type UsageReport, -} from "@oh-my-pi/pi-ai"; +import type { AssistantMessage, ImageContent, Message, Model, UsageReport } from "@oh-my-pi/pi-ai"; +import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models"; import type { Component, EditorTheme, LoaderMessageColorFn, OverlayHandle, SlashCommand } from "@oh-my-pi/pi-tui"; import { Container, @@ -49,7 +43,7 @@ import { import chalk from "chalk"; import { reset as resetCapabilities } from "../capability"; import { KeybindingsManager } from "../config/keybindings"; -import { MODEL_ROLES, type ModelRole } from "../config/model-registry"; +import { MODEL_ROLES, type ModelRole } from "../config/model-roles"; import { isSettingsInitialized, onStatusLineSessionAccentChanged, Settings, settings } from "../config/settings"; import { clearClaudePluginRootsCache } from "../discovery/helpers"; import type { diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index c435949b3..feeb401c7 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -19,6 +19,7 @@ import { getOpenAICodexTransportDetails, prewarmOpenAICodexResponses, } from "@oh-my-pi/pi-ai/providers/openai-codex-responses"; +import { DEFAULT_MODEL_PER_PROVIDER } from "@oh-my-pi/pi-catalog/provider-models"; import type { Component } from "@oh-my-pi/pi-tui"; import { $env, @@ -41,7 +42,6 @@ import { createApiKeyResolver } from "./config/api-key-resolver"; import { shouldEnableAppendOnlyContext } from "./config/append-only-context-mode"; import { ModelRegistry } from "./config/model-registry"; import { - defaultModelPerProvider, formatModelString, getModelMatchPreferences, parseModelPattern, @@ -1737,7 +1737,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // the winning provider (e.g. anthropic's claude-3-5-sonnet-20240620) // instead of the intended provider default (claude-sonnet-4-6). Mirrors // findInitialModel's precedence. - for (const [provider, defaultId] of Object.entries(defaultModelPerProvider)) { + for (const [provider, defaultId] of Object.entries(DEFAULT_MODEL_PER_PROVIDER)) { const preferred = fallbackCandidates.find( candidate => candidate.provider === provider && candidate.id === defaultId, ); @@ -2349,7 +2349,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } if (model?.api === "openai-codex-responses") { - const codexModel = model; + // `.api` equality doesn't narrow the generic; the guard makes this cast sound. + const codexModel = model as Model<"openai-codex-responses">; const codexTransport = getOpenAICodexTransportDetails(codexModel, { sessionId: providerSessionId, baseUrl: codexModel.baseUrl, diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 607246adb..f296ca5d5 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -79,14 +79,14 @@ import { clearAnthropicFastModeFallback, deriveClaudeDeviceId, Effort, - getSupportedEfforts, isContextOverflow, isUsageLimitError, - modelsAreEqual, parseRateLimitReason, resolveServiceTier, streamSimple, } from "@oh-my-pi/pi-ai"; +import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking"; +import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models"; import { countTokens, MacOSPowerAssertion } from "@oh-my-pi/pi-natives"; import { extractRetryHint, @@ -105,7 +105,7 @@ import { classifyDifficulty } from "../auto-thinking/classifier"; import { reset as resetCapabilities } from "../capability"; import type { Rule } from "../capability/rule"; import { shouldEnableAppendOnlyContext } from "../config/append-only-context-mode"; -import { MODEL_ROLE_IDS, type ModelRegistry } from "../config/model-registry"; +import type { ModelRegistry } from "../config/model-registry"; import { extractExplicitThinkingSelector, formatModelSelectorValue, @@ -115,6 +115,7 @@ import { type ResolvedModelRoleValue, resolveModelRoleValue, } from "../config/model-resolver"; +import { MODEL_ROLE_IDS } from "../config/model-roles"; import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates"; import type { Settings, SkillsSettings } from "../config/settings"; import { onAppendOnlyModeChanged } from "../config/settings"; diff --git a/packages/coding-agent/src/thinking.ts b/packages/coding-agent/src/thinking.ts index 51698aecd..8c470aa7f 100644 --- a/packages/coding-agent/src/thinking.ts +++ b/packages/coding-agent/src/thinking.ts @@ -1,5 +1,6 @@ import { type ResolvedThinkingLevel, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; -import { clampThinkingLevelForModel, Effort, getSupportedEfforts, type Model, THINKING_EFFORTS } from "@oh-my-pi/pi-ai"; +import { Effort, type Model, THINKING_EFFORTS } from "@oh-my-pi/pi-ai"; +import { clampThinkingLevelForModel, getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking"; /** * Metadata used to render thinking selector values in the coding-agent UI. diff --git a/packages/coding-agent/src/tools/image-gen.ts b/packages/coding-agent/src/tools/image-gen.ts index 263d3d6fd..d7adb3301 100644 --- a/packages/coding-agent/src/tools/image-gen.ts +++ b/packages/coding-agent/src/tools/image-gen.ts @@ -1,20 +1,14 @@ import * as os from "node:os"; import * as path from "node:path"; -import { - type ApiKey, - type FetchImpl, - getAntigravityUserAgent, - getEnvApiKey, - type Model, - withAuth, -} from "@oh-my-pi/pi-ai"; +import { type ApiKey, type FetchImpl, getEnvApiKey, type Model, withAuth } from "@oh-my-pi/pi-ai"; import { CODEX_BASE_URL, getCodexAccountId, OPENAI_HEADER_VALUES, OPENAI_HEADERS, URL_PATHS, -} from "@oh-my-pi/pi-ai/providers/openai-codex/constants"; +} from "@oh-my-pi/pi-catalog/wire/codex"; +import { getAntigravityUserAgent } from "@oh-my-pi/pi-catalog/wire/gemini-headers"; import { $env, isEnoent, diff --git a/packages/coding-agent/src/web/search/providers/codex.ts b/packages/coding-agent/src/web/search/providers/codex.ts index 6ecd551c8..e2bd085e3 100644 --- a/packages/coding-agent/src/web/search/providers/codex.ts +++ b/packages/coding-agent/src/web/search/providers/codex.ts @@ -7,8 +7,9 @@ * SQLite store, never POSTs the broker sentinel to an OpenAI token endpoint. */ import * as os from "node:os"; -import { type AuthStorage, type FetchImpl, getBundledModels } from "@oh-my-pi/pi-ai"; +import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai"; import { decodeJwt } from "@oh-my-pi/pi-ai/oauth/openai-codex"; +import { getBundledModels } from "@oh-my-pi/pi-catalog/models"; import { $env, readSseJson } from "@oh-my-pi/pi-utils"; import packageJson from "../../../../package.json" with { type: "json" }; import type { SearchResponse, SearchSource } from "../../../web/search/types"; diff --git a/packages/coding-agent/src/web/search/providers/gemini.ts b/packages/coding-agent/src/web/search/providers/gemini.ts index 80741880b..8a3780a51 100644 --- a/packages/coding-agent/src/web/search/providers/gemini.ts +++ b/packages/coding-agent/src/web/search/providers/gemini.ts @@ -8,13 +8,12 @@ * sibling SQLite store and never POSTs the broker sentinel to a Google token * endpoint. */ +import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai"; import { ANTIGRAVITY_SYSTEM_INSTRUCTION, - type AuthStorage, - type FetchImpl, getAntigravityUserAgent, getGeminiCliHeaders, -} from "@oh-my-pi/pi-ai"; +} from "@oh-my-pi/pi-catalog/wire/gemini-headers"; import { fetchWithRetry } from "@oh-my-pi/pi-utils"; import type { SearchCitation, SearchResponse, SearchSource } from "../../../web/search/types"; diff --git a/packages/coding-agent/test/agent-session-acp-permission.test.ts b/packages/coding-agent/test/agent-session-acp-permission.test.ts index 94c6936c8..bf4aec878 100644 --- a/packages/coding-agent/test/agent-session-acp-permission.test.ts +++ b/packages/coding-agent/test/agent-session-acp-permission.test.ts @@ -7,9 +7,9 @@ */ import { afterEach, beforeEach, expect, it, spyOn } from "bun:test"; import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; -import { getBundledModel } from "@oh-my-pi/pi-ai"; import { createMockModel, type MockModelOptions } from "@oh-my-pi/pi-ai/providers/mock"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { EditTool } from "@oh-my-pi/pi-coding-agent/edit"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; diff --git a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts index a74f6bf1b..89dba2558 100644 --- a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts +++ b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { loadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"; diff --git a/packages/coding-agent/test/agent-session-bash-detach.test.ts b/packages/coding-agent/test/agent-session-bash-detach.test.ts index 42e670504..926e17da7 100644 --- a/packages/coding-agent/test/agent-session-bash-detach.test.ts +++ b/packages/coding-agent/test/agent-session-bash-detach.test.ts @@ -42,8 +42,8 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-core"; -import { getBundledModel } from "@oh-my-pi/pi-ai"; import { createMockModel, type MockResponse } from "@oh-my-pi/pi-ai/providers/mock"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; diff --git a/packages/coding-agent/test/agent-session-before-agent-start-attribution.test.ts b/packages/coding-agent/test/agent-session-before-agent-start-attribution.test.ts index 1202902c4..f00eeb411 100644 --- a/packages/coding-agent/test/agent-session-before-agent-start-attribution.test.ts +++ b/packages/coding-agent/test/agent-session-before-agent-start-attribution.test.ts @@ -1,9 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent, type AgentMessage } from "@oh-my-pi/pi-agent-core"; -import { getBundledModel, type Message } from "@oh-my-pi/pi-ai"; +import type { Message } from "@oh-my-pi/pi-ai"; import { inferCopilotInitiator } from "@oh-my-pi/pi-ai/providers/github-copilot-headers"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import type { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions"; diff --git a/packages/coding-agent/test/agent-session-branching.test.ts b/packages/coding-agent/test/agent-session-branching.test.ts index ba955388d..4cb5c478d 100644 --- a/packages/coding-agent/test/agent-session-branching.test.ts +++ b/packages/coding-agent/test/agent-session-branching.test.ts @@ -12,7 +12,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { getBundledModel } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; diff --git a/packages/coding-agent/test/agent-session-compaction.test.ts b/packages/coding-agent/test/agent-session-compaction.test.ts index 4b62a22ea..0d01d06a1 100644 --- a/packages/coding-agent/test/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-compaction.test.ts @@ -12,7 +12,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { getBundledModel } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; diff --git a/packages/coding-agent/test/agent-session-concurrent.test.ts b/packages/coding-agent/test/agent-session-concurrent.test.ts index 9160918af..a1809766d 100644 --- a/packages/coding-agent/test/agent-session-concurrent.test.ts +++ b/packages/coding-agent/test/agent-session-concurrent.test.ts @@ -8,9 +8,10 @@ import * as os from "node:os"; import * as path from "node:path"; import { scheduler } from "node:timers/promises"; import { Agent, AgentBusyError, type AgentTool } from "@oh-my-pi/pi-agent-core"; -import { type AssistantMessage, getBundledModel, type Message, type ToolCall } from "@oh-my-pi/pi-ai"; +import type { AssistantMessage, Message, ToolCall } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { AsyncJobManager } from "@oh-my-pi/pi-coding-agent/async"; import type { Rule } from "@oh-my-pi/pi-coding-agent/capability/rule"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; diff --git a/packages/coding-agent/test/agent-session-eager-todo.test.ts b/packages/coding-agent/test/agent-session-eager-todo.test.ts index 5d4688835..6895648a0 100644 --- a/packages/coding-agent/test/agent-session-eager-todo.test.ts +++ b/packages/coding-agent/test/agent-session-eager-todo.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as path from "node:path"; import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-core"; -import { type AssistantMessage, getBundledModel, type TextContent, type ToolCall } from "@oh-my-pi/pi-ai"; +import type { AssistantMessage, TextContent, ToolCall } from "@oh-my-pi/pi-ai"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; diff --git a/packages/coding-agent/test/agent-session-force-tool-choice.test.ts b/packages/coding-agent/test/agent-session-force-tool-choice.test.ts index 4793a1b77..88a9f0b55 100644 --- a/packages/coding-agent/test/agent-session-force-tool-choice.test.ts +++ b/packages/coding-agent/test/agent-session-force-tool-choice.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, expect, it } from "bun:test"; import * as path from "node:path"; import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; -import { getBundledModel } from "@oh-my-pi/pi-ai"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; diff --git a/packages/coding-agent/test/agent-session-handoff.test.ts b/packages/coding-agent/test/agent-session-handoff.test.ts index 3ecb612aa..c095c9806 100644 --- a/packages/coding-agent/test/agent-session-handoff.test.ts +++ b/packages/coding-agent/test/agent-session-handoff.test.ts @@ -3,8 +3,8 @@ import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction"; import type { AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { ExtensionRunner, loadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions"; diff --git a/packages/coding-agent/test/agent-session-manual-retry.test.ts b/packages/coding-agent/test/agent-session-manual-retry.test.ts index 0fa4809a8..b22194f59 100644 --- a/packages/coding-agent/test/agent-session-manual-retry.test.ts +++ b/packages/coding-agent/test/agent-session-manual-retry.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { type AssistantMessage, getBundledModel } from "@oh-my-pi/pi-ai"; +import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; diff --git a/packages/coding-agent/test/agent-session-model-persistence.test.ts b/packages/coding-agent/test/agent-session-model-persistence.test.ts index 76f089355..391993d05 100644 --- a/packages/coding-agent/test/agent-session-model-persistence.test.ts +++ b/packages/coding-agent/test/agent-session-model-persistence.test.ts @@ -1,7 +1,8 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { type Api, Effort, getBundledModel, type Model } from "@oh-my-pi/pi-ai"; +import { type Api, Effort, type Model } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { type CreateAgentSessionResult, createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; diff --git a/packages/coding-agent/test/agent-session-openai-responses-replay.test.ts b/packages/coding-agent/test/agent-session-openai-responses-replay.test.ts index fd5452369..ff4164f05 100644 --- a/packages/coding-agent/test/agent-session-openai-responses-replay.test.ts +++ b/packages/coding-agent/test/agent-session-openai-responses-replay.test.ts @@ -2,7 +2,6 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:te import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import type { AssistantMessage, Message, @@ -12,6 +11,7 @@ import type { Usage, } from "@oh-my-pi/pi-ai/types"; import { createOpenAIResponsesHistoryPayload } from "@oh-my-pi/pi-ai/utils"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; diff --git a/packages/coding-agent/test/agent-session-python-cleanup.test.ts b/packages/coding-agent/test/agent-session-python-cleanup.test.ts index c97f93ec7..d11fdf4fe 100644 --- a/packages/coding-agent/test/agent-session-python-cleanup.test.ts +++ b/packages/coding-agent/test/agent-session-python-cleanup.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from "bun: import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { getBundledModel } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import * as pythonExecutor from "@oh-my-pi/pi-coding-agent/eval/py/executor"; import type { PythonKernel as PythonKernelInstance } from "@oh-my-pi/pi-coding-agent/eval/py/kernel"; diff --git a/packages/coding-agent/test/agent-session-resolve-reminder.test.ts b/packages/coding-agent/test/agent-session-resolve-reminder.test.ts index b47b35d91..a41c658b5 100644 --- a/packages/coding-agent/test/agent-session-resolve-reminder.test.ts +++ b/packages/coding-agent/test/agent-session-resolve-reminder.test.ts @@ -3,8 +3,8 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { getBundledModel } from "@oh-my-pi/pi-ai"; import { createMockModel, type MockModel } from "@oh-my-pi/pi-ai/providers/mock"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; diff --git a/packages/coding-agent/test/agent-session-retry-cap.test.ts b/packages/coding-agent/test/agent-session-retry-cap.test.ts index d6a4b8827..69123439f 100644 --- a/packages/coding-agent/test/agent-session-retry-cap.test.ts +++ b/packages/coding-agent/test/agent-session-retry-cap.test.ts @@ -2,8 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { scheduler } from "node:timers/promises"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { type ApiKeyResolveContext, type AssistantMessage, getBundledModel } from "@oh-my-pi/pi-ai"; +import type { ApiKeyResolveContext, AssistantMessage } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; diff --git a/packages/coding-agent/test/agent-session-retry-fallback.test.ts b/packages/coding-agent/test/agent-session-retry-fallback.test.ts index 54c852f5f..56355fb92 100644 --- a/packages/coding-agent/test/agent-session-retry-fallback.test.ts +++ b/packages/coding-agent/test/agent-session-retry-fallback.test.ts @@ -2,8 +2,10 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import * as path from "node:path"; import { scheduler } from "node:timers/promises"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { type AssistantMessage, Effort, getBundledModel, type Model, writeModelCache } from "@oh-my-pi/pi-ai"; +import { type AssistantMessage, Effort, type Model } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; +import { writeModelCache } from "@oh-my-pi/pi-catalog/model-cache"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; diff --git a/packages/coding-agent/test/agent-session-role-thinking.test.ts b/packages/coding-agent/test/agent-session-role-thinking.test.ts index c7b002ff4..80e33f065 100644 --- a/packages/coding-agent/test/agent-session-role-thinking.test.ts +++ b/packages/coding-agent/test/agent-session-role-thinking.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { Effort, getBundledModel } from "@oh-my-pi/pi-ai"; +import { Effort } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import * as autoThinkingClassifier from "@oh-my-pi/pi-coding-agent/auto-thinking/classifier"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; diff --git a/packages/coding-agent/test/agent-session-silent-abort.test.ts b/packages/coding-agent/test/agent-session-silent-abort.test.ts index 6e38d0994..d57258f25 100644 --- a/packages/coding-agent/test/agent-session-silent-abort.test.ts +++ b/packages/coding-agent/test/agent-session-silent-abort.test.ts @@ -16,7 +16,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, TextContent } from "@oh-my-pi/pi-ai"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { SecretObfuscator } from "@oh-my-pi/pi-coding-agent/secrets/obfuscator"; diff --git a/packages/coding-agent/test/agent-session-skill-keywords.test.ts b/packages/coding-agent/test/agent-session-skill-keywords.test.ts index 43afc0655..895b4d1d6 100644 --- a/packages/coding-agent/test/agent-session-skill-keywords.test.ts +++ b/packages/coding-agent/test/agent-session-skill-keywords.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { getBundledModel, type TextContent } from "@oh-my-pi/pi-ai"; +import type { TextContent } from "@oh-my-pi/pi-ai"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { WORKFLOW_NOTICE } from "@oh-my-pi/pi-coding-agent/modes/workflow"; diff --git a/packages/coding-agent/test/agent-session-user-shortcut-hooks.test.ts b/packages/coding-agent/test/agent-session-user-shortcut-hooks.test.ts index af00eb53e..d779c92ca 100644 --- a/packages/coding-agent/test/agent-session-user-shortcut-hooks.test.ts +++ b/packages/coding-agent/test/agent-session-user-shortcut-hooks.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { getBundledModel } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import * as pythonExecutor from "@oh-my-pi/pi-coding-agent/eval/py/executor"; diff --git a/packages/coding-agent/test/auto-thinking-classifier.test.ts b/packages/coding-agent/test/auto-thinking-classifier.test.ts index 8d712ef3c..3d4eb539e 100644 --- a/packages/coding-agent/test/auto-thinking-classifier.test.ts +++ b/packages/coding-agent/test/auto-thinking-classifier.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; -import { Effort, getBundledModel } from "@oh-my-pi/pi-ai"; +import { Effort } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { parseDifficultyBucket, parseDifficultyLevel } from "@oh-my-pi/pi-coding-agent/auto-thinking/classifier"; import { AUTO_THINKING, diff --git a/packages/coding-agent/test/commit-agentic-attribution.test.ts b/packages/coding-agent/test/commit-agentic-attribution.test.ts index 820c7cbb8..54bf677fb 100644 --- a/packages/coding-agent/test/commit-agentic-attribution.test.ts +++ b/packages/coding-agent/test/commit-agentic-attribution.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { runCommitAgentSession } from "@oh-my-pi/pi-coding-agent/commit/agentic/agent"; import * as toolsModule from "@oh-my-pi/pi-coding-agent/commit/agentic/tools"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; diff --git a/packages/coding-agent/test/commit-model-selection-role-thinking.test.ts b/packages/coding-agent/test/commit-model-selection-role-thinking.test.ts index 498ce011d..4c315794c 100644 --- a/packages/coding-agent/test/commit-model-selection-role-thinking.test.ts +++ b/packages/coding-agent/test/commit-model-selection-role-thinking.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { Effort, getBundledModel } from "@oh-my-pi/pi-ai"; +import { Effort } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { resolvePrimaryModel, resolveSmolModel } from "@oh-my-pi/pi-coding-agent/commit/model-selection"; function getModelOrThrow(id: string) { diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index c52cc8d40..e591a5005 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -7,7 +7,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { getBundledModel } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { diff --git a/packages/coding-agent/test/compaction-prefer-current-model.test.ts b/packages/coding-agent/test/compaction-prefer-current-model.test.ts index d555bc91d..f21ffe511 100644 --- a/packages/coding-agent/test/compaction-prefer-current-model.test.ts +++ b/packages/coding-agent/test/compaction-prefer-current-model.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction"; -import { getBundledModel } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index f16de3475..8c7912447 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -12,9 +12,9 @@ import { shouldCompact, } from "@oh-my-pi/pi-agent-core/compaction/compaction"; import * as ai from "@oh-my-pi/pi-ai"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { encodeTextSignatureV1 } from "@oh-my-pi/pi-ai/providers/openai-responses-shared"; import type { AssistantMessage, Model, ProviderPayload, Usage } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { buildSessionContext, type CompactionEntry, diff --git a/packages/coding-agent/test/edit-auto-generated-regressions.test.ts b/packages/coding-agent/test/edit-auto-generated-regressions.test.ts index 408d1ebef..78c589783 100644 --- a/packages/coding-agent/test/edit-auto-generated-regressions.test.ts +++ b/packages/coding-agent/test/edit-auto-generated-regressions.test.ts @@ -16,9 +16,10 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; -import { type AssistantMessage, getBundledModel, type StopReason, type ToolCall } from "@oh-my-pi/pi-ai"; +import type { AssistantMessage, StopReason, ToolCall } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { EditTool } from "@oh-my-pi/pi-coding-agent/edit"; diff --git a/packages/coding-agent/test/input-controller-skill-queue.test.ts b/packages/coding-agent/test/input-controller-skill-queue.test.ts index b4ea43b0c..14c070c12 100644 --- a/packages/coding-agent/test/input-controller-skill-queue.test.ts +++ b/packages/coding-agent/test/input-controller-skill-queue.test.ts @@ -20,7 +20,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { EventController } from "@oh-my-pi/pi-coding-agent/modes/controllers/event-controller"; diff --git a/packages/coding-agent/test/issue-775-repro.test.ts b/packages/coding-agent/test/issue-775-repro.test.ts index 5f4dff837..766220959 100644 --- a/packages/coding-agent/test/issue-775-repro.test.ts +++ b/packages/coding-agent/test/issue-775-repro.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { Effort, getBundledModel, type Model } from "@oh-my-pi/pi-ai"; +import { Effort, type Model } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; diff --git a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts index dc0b30556..67c5c951f 100644 --- a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts +++ b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction"; -import { getBundledModel } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; diff --git a/packages/coding-agent/test/keybindings-escape-components.test.ts b/packages/coding-agent/test/keybindings-escape-components.test.ts index 5f24a0027..1c2c16298 100644 --- a/packages/coding-agent/test/keybindings-escape-components.test.ts +++ b/packages/coding-agent/test/keybindings-escape-components.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { KeybindingsManager } from "@oh-my-pi/pi-coding-agent/config/keybindings"; import type { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; diff --git a/packages/coding-agent/test/model-discovery.test.ts b/packages/coding-agent/test/model-discovery.test.ts new file mode 100644 index 000000000..a03de048a --- /dev/null +++ b/packages/coding-agent/test/model-discovery.test.ts @@ -0,0 +1,610 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Effort, type FetchImpl, type Model } from "@oh-my-pi/pi-ai"; +import { writeModelCache } from "@oh-my-pi/pi-catalog/model-cache"; +import { kNoAuth, ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { resetSettingsForTest } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import { Snowflake } from "@oh-my-pi/pi-utils"; + +describe("ModelRegistry runtime discovery", () => { + let tempDir: string; + let modelsJsonPath: string; + let cacheDbPath: string; + let authStorage: AuthStorage; + let originalOllamaBaseUrl: string | undefined; + let originalOllamaHost: string | undefined; + let originalOllamaContextLength: string | undefined; + + beforeEach(async () => { + resetSettingsForTest(); + originalOllamaBaseUrl = Bun.env.OLLAMA_BASE_URL; + originalOllamaHost = Bun.env.OLLAMA_HOST; + originalOllamaContextLength = Bun.env.OLLAMA_CONTEXT_LENGTH; + delete Bun.env.OLLAMA_BASE_URL; + delete Bun.env.OLLAMA_HOST; + delete Bun.env.OLLAMA_CONTEXT_LENGTH; + tempDir = path.join(os.tmpdir(), `pi-test-model-registry-${Snowflake.next()}`); + fs.mkdirSync(tempDir, { recursive: true }); + modelsJsonPath = path.join(tempDir, "models.json"); + cacheDbPath = path.join(tempDir, "models.db"); + // In-memory auth DB: tests need a fresh, isolated credential store per case but + // never reopen it from disk, so :memory: avoids the WAL/chmod disk-open cost + // (~3ms/test) while preserving per-test isolation. + authStorage = await AuthStorage.create(":memory:"); + }); + + afterEach(() => { + resetSettingsForTest(); + if (originalOllamaBaseUrl === undefined) { + delete Bun.env.OLLAMA_BASE_URL; + } else { + Bun.env.OLLAMA_BASE_URL = originalOllamaBaseUrl; + } + if (originalOllamaHost === undefined) { + delete Bun.env.OLLAMA_HOST; + } else { + Bun.env.OLLAMA_HOST = originalOllamaHost; + } + if (originalOllamaContextLength === undefined) { + delete Bun.env.OLLAMA_CONTEXT_LENGTH; + } else { + Bun.env.OLLAMA_CONTEXT_LENGTH = originalOllamaContextLength; + } + authStorage.close(); + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true }); + } + }); + + function writeCachedOllamaModels(models: Model<"openai-completions">[]) { + writeModelCache("ollama", Date.now(), models, true, "", cacheDbPath); + } + + function getModelsForProvider(registry: ModelRegistry, provider: string) { + return registry.getAll().filter(m => m.provider === provider); + } + + function withEnv(name: "OLLAMA_BASE_URL" | "OLLAMA_CONTEXT_LENGTH" | "OLLAMA_HOST", value: string | undefined) { + const original = Bun.env[name]; + if (value === undefined) { + delete Bun.env[name]; + } else { + Bun.env[name] = value; + } + return { + [Symbol.dispose]() { + if (original === undefined) { + delete Bun.env[name]; + } else { + Bun.env[name] = original; + } + }, + }; + } + + /** Write raw providers config (for mixed override/replacement scenarios) */ + function writeRawModelsJson(providers: Record) { + fs.writeFileSync(modelsJsonPath, JSON.stringify({ providers })); + } + + function mockOllamaDiscovery( + modelNames: string[], + endpoint = "http://127.0.0.1:11434", + showPayload: Record = { capabilities: ["completion"] }, + ): FetchImpl { + return async input => { + const url = String(input); + if (url === `${endpoint}/api/tags`) { + return new Response(JSON.stringify({ models: modelNames.map(name => ({ name })) }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === `${endpoint}/api/show`) { + return new Response(JSON.stringify(showPayload), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`Unexpected URL: ${url}`); + }; + } + + test("auto-discovers ollama models without provider config", async () => { + const fetchMock = mockOllamaDiscovery(["phi4-mini"]); + const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); + await registry.refresh(); + const ollamaModels = getModelsForProvider(registry, "ollama"); + expect(ollamaModels.some(m => m.id === "phi4-mini")).toBe(true); + expect(registry.getAvailable().some(m => m.provider === "ollama" && m.id === "phi4-mini")).toBe(true); + expect(await registry.getApiKey(ollamaModels[0])).toBe(kNoAuth); + }); + + test("uses OLLAMA_HOST for implicit ollama discovery", async () => { + using _baseUrl = withEnv("OLLAMA_BASE_URL", undefined); + using _host = withEnv("OLLAMA_HOST", "ollama.lan:12345"); + const fetchMock = mockOllamaDiscovery(["phi4-mini"], "http://ollama.lan:12345"); + const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); + await registry.refresh(); + + const model = registry.find("ollama", "phi4-mini"); + expect(model?.baseUrl).toBe("http://ollama.lan:12345/v1"); + }); + + test("keeps OLLAMA_BASE_URL precedence over OLLAMA_HOST", async () => { + using _baseUrl = withEnv("OLLAMA_BASE_URL", "http://omp-ollama.example:2222"); + using _host = withEnv("OLLAMA_HOST", "ollama-host.example:3333"); + const fetchMock = mockOllamaDiscovery(["phi4-mini"], "http://omp-ollama.example:2222"); + const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); + await registry.refresh(); + + const model = registry.find("ollama", "phi4-mini"); + expect(model?.baseUrl).toBe("http://omp-ollama.example:2222/v1"); + }); + + test("uses OLLAMA_CONTEXT_LENGTH for implicit ollama context accounting", async () => { + using _contextLength = withEnv("OLLAMA_CONTEXT_LENGTH", "16384"); + const fetchMock = mockOllamaDiscovery(["phi4-mini"]); + const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); + await registry.refresh(); + + const model = registry.find("ollama", "phi4-mini"); + expect(model?.contextWindow).toBe(16384); + expect(model?.maxTokens).toBe(16384); + }); + + test("lets OLLAMA_CONTEXT_LENGTH override ollama show metadata", async () => { + using _contextLength = withEnv("OLLAMA_CONTEXT_LENGTH", "32768"); + const fetchMock = mockOllamaDiscovery(["phi4-mini"], "http://127.0.0.1:11434", { + model_info: { + "phi4.context_length": 4096, + }, + capabilities: ["completion"], + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); + await registry.refresh(); + + const model = registry.find("ollama", "phi4-mini"); + expect(model?.contextWindow).toBe(32768); + expect(model?.maxTokens).toBe(32768); + }); + + test("discovers ollama-cloud through built-in descriptor flow without regressing local implicit ollama", async () => { + authStorage.setRuntimeApiKey("ollama-cloud", "cloud-test-key"); + + const fetchMock: FetchImpl = async (input, init) => { + const url = String(input); + if (url === "http://127.0.0.1:11434/api/tags") { + return new Response(JSON.stringify({ models: [{ name: "phi4-mini" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "http://127.0.0.1:11434/api/show") { + return new Response(JSON.stringify({ capabilities: ["completion"] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "https://ollama.com/api/tags") { + const headers = new Headers(init?.headers); + expect(headers.get("Authorization")).toBe("Bearer cloud-test-key"); + return new Response(JSON.stringify({ models: [{ name: "gpt-oss:120b" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "https://ollama.com/api/show") { + const headers = new Headers(init?.headers); + expect(headers.get("Authorization")).toBe("Bearer cloud-test-key"); + const body = JSON.parse(String(init?.body ?? "{}")) as { model?: string }; + expect(body.model).toBe("gpt-oss:120b"); + return new Response( + JSON.stringify({ + capabilities: ["completion", "thinking"], + model_info: { "gpt-oss.context_length": 262144 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + throw new Error(`Unexpected URL: ${url}`); + }; + + const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); + await registry.refresh(); + + const local = registry.find("ollama", "phi4-mini"); + const cloud = registry.find("ollama-cloud", "gpt-oss:120b"); + + expect(local?.provider).toBe("ollama"); + expect(local?.api).toBe("openai-responses"); + expect(cloud?.provider).toBe("ollama-cloud"); + expect(cloud?.api).toBe("ollama-chat"); + expect(cloud?.baseUrl).toBe("https://ollama.com"); + expect(cloud?.reasoning).toBe(true); + expect(cloud?.contextWindow).toBe(262144); + expect(await registry.getApiKey(cloud!)).toBe("cloud-test-key"); + expect(registry.getAvailable().some(model => model.provider === "ollama" && model.id === "phi4-mini")).toBe(true); + expect( + registry.getAvailable().some(model => model.provider === "ollama-cloud" && model.id === "gpt-oss:120b"), + ).toBe(true); + }); + test("discovers ollama models at runtime and treats auth:none providers as available", async () => { + writeRawModelsJson({ + ollama: { + baseUrl: "http://127.0.0.1:11434/v1", + api: "openai-completions", + auth: "none", + discovery: { type: "ollama" }, + }, + }); + + const fetchMock: FetchImpl = async input => { + const url = String(input); + if (url === "http://127.0.0.1:11434/api/tags") { + return new Response( + JSON.stringify({ + models: [{ name: "qwen2.5-coder:7b" }, { model: "llama3.2:3b", name: "llama3.2:3b" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url === "http://127.0.0.1:11434/api/show") { + return new Response(JSON.stringify({ capabilities: ["completion"] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`Unexpected URL: ${url}`); + }; + + const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); + await registry.refresh(); + + const ollamaModels = getModelsForProvider(registry, "ollama"); + expect(ollamaModels.some(m => m.id === "qwen2.5-coder:7b")).toBe(true); + expect(ollamaModels.some(m => m.id === "llama3.2:3b")).toBe(true); + + const available = registry.getAvailable().filter(m => m.provider === "ollama"); + expect(available.length).toBe(2); + expect(await registry.getApiKey(available[0])).toBe(kNoAuth); + }); + + test("normalizes cached ollama completions rows to responses on load", () => { + writeRawModelsJson({ + ollama: { + baseUrl: "http://127.0.0.1:11434/v1", + api: "openai-responses", + auth: "none", + discovery: { type: "ollama" }, + }, + }); + writeCachedOllamaModels([ + { + id: "phi4-mini", + name: "phi4-mini", + api: "openai-completions", + provider: "ollama", + baseUrl: "http://127.0.0.1:11434/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }, + ]); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const ollama = registry.find("ollama", "phi4-mini"); + + expect(ollama?.api).toBe("openai-responses"); + expect(ollama?.baseUrl).toBe("http://127.0.0.1:11434/v1"); + expect(registry.getProviderDiscoveryState("ollama")?.status).toBe("cached"); + }); + + test("discovers ollama thinking capabilities from show metadata", async () => { + writeRawModelsJson({ + ollama: { + baseUrl: "http://127.0.0.1:11434/v1", + api: "openai-completions", + auth: "none", + discovery: { type: "ollama" }, + }, + }); + + const fetchMock: FetchImpl = async (input, init) => { + const url = String(input); + if (url === "http://127.0.0.1:11434/api/tags") { + return new Response( + JSON.stringify({ + models: [{ name: "qwen3.5:397b-cloud" }, { name: "llama3.2:3b" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url === "http://127.0.0.1:11434/api/show") { + const body = JSON.parse(String(init?.body ?? "{}")) as { model?: string }; + if (body.model === "qwen3.5:397b-cloud") { + return new Response(JSON.stringify({ capabilities: ["completion", "thinking"] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (body.model === "llama3.2:3b") { + return new Response(JSON.stringify({ capabilities: ["completion"] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + } + throw new Error(`Unexpected request: ${url}`); + }; + + const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); + await registry.refresh(); + + const qwen = registry.find("ollama", "qwen3.5:397b-cloud"); + expect(qwen?.reasoning).toBe(true); + expect(qwen?.thinking).toEqual({ + mode: "effort", + minLevel: Effort.Minimal, + maxLevel: Effort.High, + }); + + const llama = registry.find("ollama", "llama3.2:3b"); + expect(llama?.reasoning).toBe(false); + }); + + test("discovers ollama context window from show model_info", async () => { + const fetchMock: FetchImpl = async (input, init) => { + const url = String(input); + if (url === "http://127.0.0.1:11434/api/tags") { + return new Response(JSON.stringify({ models: [{ name: "gemma3:4b" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "http://127.0.0.1:11434/api/show") { + const body = JSON.parse(String(init?.body ?? "{}")) as { model?: string }; + if (body.model === "gemma3:4b") { + return new Response( + JSON.stringify({ + model_info: { + "gemma3.context_length": 131072, + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + } + } + throw new Error(`Unexpected request: ${url}`); + }; + + const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); + await registry.refresh(); + + const gemma = registry.find("ollama", "gemma3:4b"); + expect(gemma?.contextWindow).toBe(131072); + expect(gemma?.maxTokens).toBe(32_768); + expect(gemma?.input).toEqual(["text"]); + expect(gemma?.reasoning).toBe(false); + }); + + test("discovery failure does not fail model registry refresh", async () => { + writeRawModelsJson({ + ollama: { + baseUrl: "http://127.0.0.1:11434", + api: "openai-completions", + auth: "none", + discovery: { type: "ollama" }, + }, + }); + + const fetchMock: FetchImpl = () => { + throw new Error("connection refused"); + }; + + const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); + await registry.refresh(); + expect(getModelsForProvider(registry, "ollama")).toHaveLength(0); + expect(registry.getError()).toBeUndefined(); + }); + test("loads cached local models before live refresh and preserves them on failure", async () => { + writeRawModelsJson({ + ollama: { + baseUrl: "http://127.0.0.1:11434/v1", + api: "openai-completions", + auth: "none", + discovery: { type: "ollama" }, + }, + }); + + { + const fetchMock = mockOllamaDiscovery(["phi4-mini"]); + const primedRegistry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); + await primedRegistry.refresh(); + } + + const failingFetch: FetchImpl = () => { + throw new Error("connection refused"); + }; + const cachedRegistry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: failingFetch }); + expect(getModelsForProvider(cachedRegistry, "ollama").some(model => model.id === "phi4-mini")).toBe(true); + expect(cachedRegistry.getProviderDiscoveryState("ollama")?.status).toBe("cached"); + + await cachedRegistry.refreshProvider("ollama"); + + expect(getModelsForProvider(cachedRegistry, "ollama").some(model => model.id === "phi4-mini")).toBe(true); + const state = cachedRegistry.getProviderDiscoveryState("ollama"); + expect(state?.status).toBe("cached"); + expect(state?.error).toContain("connection refused"); + }); + + test("reports unauthenticated discoverable providers without discarding cached models", async () => { + writeRawModelsJson({ + "custom-local": { + baseUrl: "http://127.0.0.1:11434/v1", + api: "openai-completions", + discovery: { type: "ollama" }, + }, + }); + authStorage.setRuntimeApiKey("custom-local", "test-key"); + + { + const fetchMock: FetchImpl = async input => { + const url = String(input); + if (url === "http://127.0.0.1:11434/api/tags") { + return new Response(JSON.stringify({ models: [{ name: "local-coder" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "http://127.0.0.1:11434/api/show") { + return new Response(JSON.stringify({ capabilities: ["completion"] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`Unexpected URL: ${url}`); + }; + const primedRegistry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); + await primedRegistry.refreshProvider("custom-local"); + } + + authStorage.setRuntimeApiKey("custom-local", ""); + const cachedRegistry = new ModelRegistry(authStorage, modelsJsonPath); + await cachedRegistry.refreshProvider("custom-local"); + + expect(getModelsForProvider(cachedRegistry, "custom-local").some(model => model.id === "local-coder")).toBe(true); + const state = cachedRegistry.getProviderDiscoveryState("custom-local"); + expect(state?.status).toBe("unauthenticated"); + expect(state?.models).toContain("local-coder"); + }); + test("llama.cpp discovery honors configured API key", async () => { + authStorage.setRuntimeApiKey("llama.cpp", "test-llama-key"); + const fetchMock: FetchImpl = async (input, init) => { + const url = String(input); + if (url === "http://127.0.0.1:8080/models") { + const headers = init?.headers as Headers | Record | undefined; + let authHeader: string | null = null; + if (headers instanceof Headers) { + authHeader = headers.get("Authorization"); + } else if (typeof headers === "object") { + authHeader = headers.Authorization; + } + expect(String(authHeader ?? "")).toBe("Bearer test-llama-key"); + return new Response(JSON.stringify({ data: [{ id: "llama-3.2:3b" }, { id: "mistral:7b" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "http://127.0.0.1:8080/props") { + const headers = init?.headers as Headers | Record | undefined; + let authHeader: string | null = null; + if (headers instanceof Headers) { + authHeader = headers.get("Authorization"); + } else if (typeof headers === "object") { + authHeader = headers.Authorization; + } + expect(String(authHeader ?? "")).toBe("Bearer test-llama-key"); + return new Response(JSON.stringify({ default_generation_settings: { n_ctx: 262144 } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`Unexpected URL: ${url}`); + }; + const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); + await registry.refresh(); + const llamaModels = getModelsForProvider(registry, "llama.cpp"); + expect(llamaModels.some(m => m.id === "llama-3.2:3b")).toBe(true); + const apiKey = await registry.getApiKey(llamaModels[0]); + expect(apiKey).toBe("test-llama-key"); + expect(apiKey).not.toBe(kNoAuth); + }); + test("llama.cpp discovery without API key is treated as keyless", async () => { + const fetchMock: FetchImpl = async (input, init) => { + const url = String(input); + if (url === "http://127.0.0.1:8080/models") { + const headers = init?.headers as Headers | Record | undefined; + let authHeader: string | null = null; + if (headers instanceof Headers) { + authHeader = headers.get("Authorization"); + } else if (typeof headers === "object") { + authHeader = headers.Authorization; + } + // When no API key, headers should be empty object or undefined + expect(authHeader).toBeUndefined(); + return new Response(JSON.stringify({ data: [{ id: "llama-3.2:3b" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "http://127.0.0.1:8080/props") { + const headers = init?.headers as Headers | Record | undefined; + let authHeader: string | null = null; + if (headers instanceof Headers) { + authHeader = headers.get("Authorization"); + } else if (typeof headers === "object") { + authHeader = headers.Authorization; + } + expect(authHeader).toBeUndefined(); + return new Response(JSON.stringify({ default_generation_settings: { n_ctx: 262144 } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`Unexpected URL: ${url}`); + }; + const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); + await registry.refresh(); + const state = registry.getProviderDiscoveryState("llama.cpp"); + if (state?.status !== "ok") { + throw new Error(`Discovery failed with status ${state?.status}: ${state?.error}`); + } + const llamaModels = getModelsForProvider(registry, "llama.cpp"); + const apiKey = await registry.getApiKey(llamaModels[0]); + expect(apiKey).toBe(kNoAuth); + }); + test("llama.cpp discovery reads context window from props n_ctx", async () => { + const fetchMock: FetchImpl = async input => { + const url = String(input); + if (url === "http://127.0.0.1:8080/models") { + return new Response(JSON.stringify({ data: [{ id: "qwen35-35b-a3b" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "http://127.0.0.1:8080/props") { + return new Response( + JSON.stringify({ + default_generation_settings: { + n_ctx: 262144, + }, + modalities: { + vision: true, + audio: false, + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + } + throw new Error(`Unexpected URL: ${url}`); + }; + const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); + await registry.refresh(); + const llama = registry.find("llama.cpp", "qwen35-35b-a3b"); + expect(llama?.contextWindow).toBe(262144); + expect(llama?.maxTokens).toBe(32_768); + expect(llama?.input).toEqual(["text", "image"]); + }); +}); diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 9dbd31276..89e987417 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -2,15 +2,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { - Effort, - type FetchImpl, - type Model, - type OpenAICompat, - type ThinkingConfig, - writeModelCache, -} from "@oh-my-pi/pi-ai"; -import { kNoAuth, ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { Effort, type FetchImpl, type Model, type OpenAICompat, type ThinkingConfig } from "@oh-my-pi/pi-ai"; +import { writeModelCache } from "@oh-my-pi/pi-catalog/model-cache"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { Snowflake } from "@oh-my-pi/pi-utils"; @@ -114,10 +108,6 @@ describe("ModelRegistry", () => { fs.writeFileSync(modelsJsonPath, JSON.stringify({ providers })); } - function writeCachedOllamaModels(models: Model<"openai-completions">[]) { - writeModelCache("ollama", Date.now(), models, true, "", cacheDbPath); - } - function getModelsForProvider(registry: ModelRegistry, provider: string) { return registry.getAll().filter(m => m.provider === provider); } @@ -129,24 +119,6 @@ describe("ModelRegistry", () => { return model?.compat as OpenAICompat | undefined; } - function withEnv(name: "OLLAMA_BASE_URL" | "OLLAMA_CONTEXT_LENGTH" | "OLLAMA_HOST", value: string | undefined) { - const original = Bun.env[name]; - if (value === undefined) { - delete Bun.env[name]; - } else { - Bun.env[name] = value; - } - return { - [Symbol.dispose]() { - if (original === undefined) { - delete Bun.env[name]; - } else { - Bun.env[name] = original; - } - }, - }; - } - /** Create a baseUrl-only override (no custom models) */ function overrideConfig(baseUrl: string, headers?: Record) { return { baseUrl, ...(headers && { headers }) }; @@ -174,29 +146,6 @@ describe("ModelRegistry", () => { }; } - function mockOllamaDiscovery( - modelNames: string[], - endpoint = "http://127.0.0.1:11434", - showPayload: Record = { capabilities: ["completion"] }, - ): FetchImpl { - return async input => { - const url = String(input); - if (url === `${endpoint}/api/tags`) { - return new Response(JSON.stringify({ models: modelNames.map(name => ({ name })) }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - if (url === `${endpoint}/api/show`) { - return new Response(JSON.stringify(showPayload), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - throw new Error(`Unexpected URL: ${url}`); - }; - } - describe("canonical equivalence", () => { test("groups dotted provider variants under the bundled canonical id", () => { writeRawModelsJson({ @@ -1668,506 +1617,6 @@ describe("ModelRegistry", () => { expect(disabledProbeUrls).toEqual([]); }); }); - describe("runtime discovery", () => { - test("auto-discovers ollama models without provider config", async () => { - const fetchMock = mockOllamaDiscovery(["phi4-mini"]); - const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); - await registry.refresh(); - const ollamaModels = getModelsForProvider(registry, "ollama"); - expect(ollamaModels.some(m => m.id === "phi4-mini")).toBe(true); - expect(registry.getAvailable().some(m => m.provider === "ollama" && m.id === "phi4-mini")).toBe(true); - expect(await registry.getApiKey(ollamaModels[0])).toBe(kNoAuth); - }); - - test("uses OLLAMA_HOST for implicit ollama discovery", async () => { - using _baseUrl = withEnv("OLLAMA_BASE_URL", undefined); - using _host = withEnv("OLLAMA_HOST", "ollama.lan:12345"); - const fetchMock = mockOllamaDiscovery(["phi4-mini"], "http://ollama.lan:12345"); - const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); - await registry.refresh(); - - const model = registry.find("ollama", "phi4-mini"); - expect(model?.baseUrl).toBe("http://ollama.lan:12345/v1"); - }); - - test("keeps OLLAMA_BASE_URL precedence over OLLAMA_HOST", async () => { - using _baseUrl = withEnv("OLLAMA_BASE_URL", "http://omp-ollama.example:2222"); - using _host = withEnv("OLLAMA_HOST", "ollama-host.example:3333"); - const fetchMock = mockOllamaDiscovery(["phi4-mini"], "http://omp-ollama.example:2222"); - const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); - await registry.refresh(); - - const model = registry.find("ollama", "phi4-mini"); - expect(model?.baseUrl).toBe("http://omp-ollama.example:2222/v1"); - }); - - test("uses OLLAMA_CONTEXT_LENGTH for implicit ollama context accounting", async () => { - using _contextLength = withEnv("OLLAMA_CONTEXT_LENGTH", "16384"); - const fetchMock = mockOllamaDiscovery(["phi4-mini"]); - const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); - await registry.refresh(); - - const model = registry.find("ollama", "phi4-mini"); - expect(model?.contextWindow).toBe(16384); - expect(model?.maxTokens).toBe(16384); - }); - - test("lets OLLAMA_CONTEXT_LENGTH override ollama show metadata", async () => { - using _contextLength = withEnv("OLLAMA_CONTEXT_LENGTH", "32768"); - const fetchMock = mockOllamaDiscovery(["phi4-mini"], "http://127.0.0.1:11434", { - model_info: { - "phi4.context_length": 4096, - }, - capabilities: ["completion"], - }); - const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); - await registry.refresh(); - - const model = registry.find("ollama", "phi4-mini"); - expect(model?.contextWindow).toBe(32768); - expect(model?.maxTokens).toBe(32768); - }); - - test("discovers ollama-cloud through built-in descriptor flow without regressing local implicit ollama", async () => { - authStorage.setRuntimeApiKey("ollama-cloud", "cloud-test-key"); - - const fetchMock: FetchImpl = async (input, init) => { - const url = String(input); - if (url === "http://127.0.0.1:11434/api/tags") { - return new Response(JSON.stringify({ models: [{ name: "phi4-mini" }] }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - if (url === "http://127.0.0.1:11434/api/show") { - return new Response(JSON.stringify({ capabilities: ["completion"] }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - if (url === "https://ollama.com/api/tags") { - const headers = new Headers(init?.headers); - expect(headers.get("Authorization")).toBe("Bearer cloud-test-key"); - return new Response(JSON.stringify({ models: [{ name: "gpt-oss:120b" }] }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - if (url === "https://ollama.com/api/show") { - const headers = new Headers(init?.headers); - expect(headers.get("Authorization")).toBe("Bearer cloud-test-key"); - const body = JSON.parse(String(init?.body ?? "{}")) as { model?: string }; - expect(body.model).toBe("gpt-oss:120b"); - return new Response( - JSON.stringify({ - capabilities: ["completion", "thinking"], - model_info: { "gpt-oss.context_length": 262144 }, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - } - throw new Error(`Unexpected URL: ${url}`); - }; - - const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); - await registry.refresh(); - - const local = registry.find("ollama", "phi4-mini"); - const cloud = registry.find("ollama-cloud", "gpt-oss:120b"); - - expect(local?.provider).toBe("ollama"); - expect(local?.api).toBe("openai-responses"); - expect(cloud?.provider).toBe("ollama-cloud"); - expect(cloud?.api).toBe("ollama-chat"); - expect(cloud?.baseUrl).toBe("https://ollama.com"); - expect(cloud?.reasoning).toBe(true); - expect(cloud?.contextWindow).toBe(262144); - expect(await registry.getApiKey(cloud!)).toBe("cloud-test-key"); - expect(registry.getAvailable().some(model => model.provider === "ollama" && model.id === "phi4-mini")).toBe( - true, - ); - expect( - registry.getAvailable().some(model => model.provider === "ollama-cloud" && model.id === "gpt-oss:120b"), - ).toBe(true); - }); - test("discovers ollama models at runtime and treats auth:none providers as available", async () => { - writeRawModelsJson({ - ollama: { - baseUrl: "http://127.0.0.1:11434/v1", - api: "openai-completions", - auth: "none", - discovery: { type: "ollama" }, - }, - }); - - const fetchMock: FetchImpl = async input => { - const url = String(input); - if (url === "http://127.0.0.1:11434/api/tags") { - return new Response( - JSON.stringify({ - models: [{ name: "qwen2.5-coder:7b" }, { model: "llama3.2:3b", name: "llama3.2:3b" }], - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - } - if (url === "http://127.0.0.1:11434/api/show") { - return new Response(JSON.stringify({ capabilities: ["completion"] }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - throw new Error(`Unexpected URL: ${url}`); - }; - - const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); - await registry.refresh(); - - const ollamaModels = getModelsForProvider(registry, "ollama"); - expect(ollamaModels.some(m => m.id === "qwen2.5-coder:7b")).toBe(true); - expect(ollamaModels.some(m => m.id === "llama3.2:3b")).toBe(true); - - const available = registry.getAvailable().filter(m => m.provider === "ollama"); - expect(available.length).toBe(2); - expect(await registry.getApiKey(available[0])).toBe(kNoAuth); - }); - - test("normalizes cached ollama completions rows to responses on load", () => { - writeRawModelsJson({ - ollama: { - baseUrl: "http://127.0.0.1:11434/v1", - api: "openai-responses", - auth: "none", - discovery: { type: "ollama" }, - }, - }); - writeCachedOllamaModels([ - { - id: "phi4-mini", - name: "phi4-mini", - api: "openai-completions", - provider: "ollama", - baseUrl: "http://127.0.0.1:11434/v1", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128000, - maxTokens: 8192, - }, - ]); - - const registry = new ModelRegistry(authStorage, modelsJsonPath); - const ollama = registry.find("ollama", "phi4-mini"); - - expect(ollama?.api).toBe("openai-responses"); - expect(ollama?.baseUrl).toBe("http://127.0.0.1:11434/v1"); - expect(registry.getProviderDiscoveryState("ollama")?.status).toBe("cached"); - }); - - test("discovers ollama thinking capabilities from show metadata", async () => { - writeRawModelsJson({ - ollama: { - baseUrl: "http://127.0.0.1:11434/v1", - api: "openai-completions", - auth: "none", - discovery: { type: "ollama" }, - }, - }); - - const fetchMock: FetchImpl = async (input, init) => { - const url = String(input); - if (url === "http://127.0.0.1:11434/api/tags") { - return new Response( - JSON.stringify({ - models: [{ name: "qwen3.5:397b-cloud" }, { name: "llama3.2:3b" }], - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - } - if (url === "http://127.0.0.1:11434/api/show") { - const body = JSON.parse(String(init?.body ?? "{}")) as { model?: string }; - if (body.model === "qwen3.5:397b-cloud") { - return new Response(JSON.stringify({ capabilities: ["completion", "thinking"] }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - if (body.model === "llama3.2:3b") { - return new Response(JSON.stringify({ capabilities: ["completion"] }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - } - throw new Error(`Unexpected request: ${url}`); - }; - - const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); - await registry.refresh(); - - const qwen = registry.find("ollama", "qwen3.5:397b-cloud"); - expect(qwen?.reasoning).toBe(true); - expect(qwen?.thinking).toEqual({ - mode: "effort", - minLevel: Effort.Minimal, - maxLevel: Effort.High, - }); - - const llama = registry.find("ollama", "llama3.2:3b"); - expect(llama?.reasoning).toBe(false); - }); - - test("discovers ollama context window from show model_info", async () => { - const fetchMock: FetchImpl = async (input, init) => { - const url = String(input); - if (url === "http://127.0.0.1:11434/api/tags") { - return new Response(JSON.stringify({ models: [{ name: "gemma3:4b" }] }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - if (url === "http://127.0.0.1:11434/api/show") { - const body = JSON.parse(String(init?.body ?? "{}")) as { model?: string }; - if (body.model === "gemma3:4b") { - return new Response( - JSON.stringify({ - model_info: { - "gemma3.context_length": 131072, - }, - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ); - } - } - throw new Error(`Unexpected request: ${url}`); - }; - - const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); - await registry.refresh(); - - const gemma = registry.find("ollama", "gemma3:4b"); - expect(gemma?.contextWindow).toBe(131072); - expect(gemma?.maxTokens).toBe(32_768); - expect(gemma?.input).toEqual(["text"]); - expect(gemma?.reasoning).toBe(false); - }); - - test("discovery failure does not fail model registry refresh", async () => { - writeRawModelsJson({ - ollama: { - baseUrl: "http://127.0.0.1:11434", - api: "openai-completions", - auth: "none", - discovery: { type: "ollama" }, - }, - }); - - const fetchMock: FetchImpl = () => { - throw new Error("connection refused"); - }; - - const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); - await registry.refresh(); - expect(getModelsForProvider(registry, "ollama")).toHaveLength(0); - expect(registry.getError()).toBeUndefined(); - }); - test("loads cached local models before live refresh and preserves them on failure", async () => { - writeRawModelsJson({ - ollama: { - baseUrl: "http://127.0.0.1:11434/v1", - api: "openai-completions", - auth: "none", - discovery: { type: "ollama" }, - }, - }); - - { - const fetchMock = mockOllamaDiscovery(["phi4-mini"]); - const primedRegistry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); - await primedRegistry.refresh(); - } - - const failingFetch: FetchImpl = () => { - throw new Error("connection refused"); - }; - const cachedRegistry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: failingFetch }); - expect(getModelsForProvider(cachedRegistry, "ollama").some(model => model.id === "phi4-mini")).toBe(true); - expect(cachedRegistry.getProviderDiscoveryState("ollama")?.status).toBe("cached"); - - await cachedRegistry.refreshProvider("ollama"); - - expect(getModelsForProvider(cachedRegistry, "ollama").some(model => model.id === "phi4-mini")).toBe(true); - const state = cachedRegistry.getProviderDiscoveryState("ollama"); - expect(state?.status).toBe("cached"); - expect(state?.error).toContain("connection refused"); - }); - - test("reports unauthenticated discoverable providers without discarding cached models", async () => { - writeRawModelsJson({ - "custom-local": { - baseUrl: "http://127.0.0.1:11434/v1", - api: "openai-completions", - discovery: { type: "ollama" }, - }, - }); - authStorage.setRuntimeApiKey("custom-local", "test-key"); - - { - const fetchMock: FetchImpl = async input => { - const url = String(input); - if (url === "http://127.0.0.1:11434/api/tags") { - return new Response(JSON.stringify({ models: [{ name: "local-coder" }] }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - if (url === "http://127.0.0.1:11434/api/show") { - return new Response(JSON.stringify({ capabilities: ["completion"] }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - throw new Error(`Unexpected URL: ${url}`); - }; - const primedRegistry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); - await primedRegistry.refreshProvider("custom-local"); - } - - authStorage.setRuntimeApiKey("custom-local", ""); - const cachedRegistry = new ModelRegistry(authStorage, modelsJsonPath); - await cachedRegistry.refreshProvider("custom-local"); - - expect(getModelsForProvider(cachedRegistry, "custom-local").some(model => model.id === "local-coder")).toBe( - true, - ); - const state = cachedRegistry.getProviderDiscoveryState("custom-local"); - expect(state?.status).toBe("unauthenticated"); - expect(state?.models).toContain("local-coder"); - }); - test("llama.cpp discovery honors configured API key", async () => { - authStorage.setRuntimeApiKey("llama.cpp", "test-llama-key"); - const fetchMock: FetchImpl = async (input, init) => { - const url = String(input); - if (url === "http://127.0.0.1:8080/models") { - const headers = init?.headers as Headers | Record | undefined; - let authHeader: string | null = null; - if (headers instanceof Headers) { - authHeader = headers.get("Authorization"); - } else if (typeof headers === "object") { - authHeader = headers.Authorization; - } - expect(String(authHeader ?? "")).toBe("Bearer test-llama-key"); - return new Response(JSON.stringify({ data: [{ id: "llama-3.2:3b" }, { id: "mistral:7b" }] }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - if (url === "http://127.0.0.1:8080/props") { - const headers = init?.headers as Headers | Record | undefined; - let authHeader: string | null = null; - if (headers instanceof Headers) { - authHeader = headers.get("Authorization"); - } else if (typeof headers === "object") { - authHeader = headers.Authorization; - } - expect(String(authHeader ?? "")).toBe("Bearer test-llama-key"); - return new Response(JSON.stringify({ default_generation_settings: { n_ctx: 262144 } }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - throw new Error(`Unexpected URL: ${url}`); - }; - const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); - await registry.refresh(); - const llamaModels = getModelsForProvider(registry, "llama.cpp"); - expect(llamaModels.some(m => m.id === "llama-3.2:3b")).toBe(true); - const apiKey = await registry.getApiKey(llamaModels[0]); - expect(apiKey).toBe("test-llama-key"); - expect(apiKey).not.toBe(kNoAuth); - }); - test("llama.cpp discovery without API key is treated as keyless", async () => { - const fetchMock: FetchImpl = async (input, init) => { - const url = String(input); - if (url === "http://127.0.0.1:8080/models") { - const headers = init?.headers as Headers | Record | undefined; - let authHeader: string | null = null; - if (headers instanceof Headers) { - authHeader = headers.get("Authorization"); - } else if (typeof headers === "object") { - authHeader = headers.Authorization; - } - // When no API key, headers should be empty object or undefined - expect(authHeader).toBeUndefined(); - return new Response(JSON.stringify({ data: [{ id: "llama-3.2:3b" }] }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - if (url === "http://127.0.0.1:8080/props") { - const headers = init?.headers as Headers | Record | undefined; - let authHeader: string | null = null; - if (headers instanceof Headers) { - authHeader = headers.get("Authorization"); - } else if (typeof headers === "object") { - authHeader = headers.Authorization; - } - expect(authHeader).toBeUndefined(); - return new Response(JSON.stringify({ default_generation_settings: { n_ctx: 262144 } }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - throw new Error(`Unexpected URL: ${url}`); - }; - const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); - await registry.refresh(); - const state = registry.getProviderDiscoveryState("llama.cpp"); - if (state?.status !== "ok") { - throw new Error(`Discovery failed with status ${state?.status}: ${state?.error}`); - } - const llamaModels = getModelsForProvider(registry, "llama.cpp"); - const apiKey = await registry.getApiKey(llamaModels[0]); - expect(apiKey).toBe(kNoAuth); - }); - test("llama.cpp discovery reads context window from props n_ctx", async () => { - const fetchMock: FetchImpl = async input => { - const url = String(input); - if (url === "http://127.0.0.1:8080/models") { - return new Response(JSON.stringify({ data: [{ id: "qwen35-35b-a3b" }] }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - if (url === "http://127.0.0.1:8080/props") { - return new Response( - JSON.stringify({ - default_generation_settings: { - n_ctx: 262144, - }, - modalities: { - vision: true, - audio: false, - }, - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ); - } - throw new Error(`Unexpected URL: ${url}`); - }; - const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock }); - await registry.refresh(); - const llama = registry.find("llama.cpp", "qwen35-35b-a3b"); - expect(llama?.contextWindow).toBe(262144); - expect(llama?.maxTokens).toBe(32_768); - expect(llama?.input).toEqual(["text", "image"]); - }); - }); describe("bundled Anthropic catalog availability", () => { test("includes native Opus 4.7 in available models when Anthropic auth exists", async () => { await authStorage.set("anthropic", [{ type: "api_key", key: "sk-ant-api-test" }]); diff --git a/packages/coding-agent/test/model-selector-role-badge-thinking.test.ts b/packages/coding-agent/test/model-selector-role-badge-thinking.test.ts index aa94c5858..1c5762049 100644 --- a/packages/coding-agent/test/model-selector-role-badge-thinking.test.ts +++ b/packages/coding-agent/test/model-selector-role-badge-thinking.test.ts @@ -1,6 +1,7 @@ import { beforeAll, describe, expect, test, vi } from "bun:test"; import { stripVTControlCharacters } from "node:util"; -import { getBundledModel, type Model } from "@oh-my-pi/pi-ai"; +import type { Model } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import type { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { ModelSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/model-selector"; diff --git a/packages/coding-agent/test/role-info.test.ts b/packages/coding-agent/test/role-info.test.ts index 5f3a5d116..0427b43e9 100644 --- a/packages/coding-agent/test/role-info.test.ts +++ b/packages/coding-agent/test/role-info.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { getRoleInfo } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { getRoleInfo } from "@oh-my-pi/pi-coding-agent/config/model-roles"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; describe("getRoleInfo", () => { diff --git a/packages/coding-agent/test/role-thinking-helper-propagation.test.ts b/packages/coding-agent/test/role-thinking-helper-propagation.test.ts index 75ddb8019..d04c8d084 100644 --- a/packages/coding-agent/test/role-thinking-helper-propagation.test.ts +++ b/packages/coding-agent/test/role-thinking-helper-propagation.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as ai from "@oh-my-pi/pi-ai"; -import { Effort, getBundledModel } from "@oh-my-pi/pi-ai"; +import { Effort } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { generateCommitMessage } from "@oh-my-pi/pi-coding-agent/utils/commit-message-generator"; import { generateSessionTitle } from "@oh-my-pi/pi-coding-agent/utils/title-generator"; diff --git a/packages/coding-agent/test/sdk-mcp-discovery.test.ts b/packages/coding-agent/test/sdk-mcp-discovery.test.ts index b7f35eecd..4f59734c0 100644 --- a/packages/coding-agent/test/sdk-mcp-discovery.test.ts +++ b/packages/coding-agent/test/sdk-mcp-discovery.test.ts @@ -3,7 +3,8 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; -import { AuthStorage, Effort, getBundledModel, type Model } from "@oh-my-pi/pi-ai"; +import { AuthStorage, Effort, type Model } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import type { CustomTool } from "@oh-my-pi/pi-coding-agent/extensibility/custom-tools/types"; diff --git a/packages/coding-agent/test/sdk-model-selection.test.ts b/packages/coding-agent/test/sdk-model-selection.test.ts index c3d7e2281..031d1fc8c 100644 --- a/packages/coding-agent/test/sdk-model-selection.test.ts +++ b/packages/coding-agent/test/sdk-model-selection.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { getBundledModel } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { createAgentSession, type ExtensionFactory } from "@oh-my-pi/pi-coding-agent/sdk"; diff --git a/packages/coding-agent/test/sdk-move-cwd.test.ts b/packages/coding-agent/test/sdk-move-cwd.test.ts index 1e634b010..d662b2a30 100644 --- a/packages/coding-agent/test/sdk-move-cwd.test.ts +++ b/packages/coding-agent/test/sdk-move-cwd.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { getBundledModel } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; diff --git a/packages/coding-agent/test/sdk-session-isolation.test.ts b/packages/coding-agent/test/sdk-session-isolation.test.ts index 7bac57a38..af6255377 100644 --- a/packages/coding-agent/test/sdk-session-isolation.test.ts +++ b/packages/coding-agent/test/sdk-session-isolation.test.ts @@ -2,7 +2,8 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { type AssistantMessage, getBundledModel } from "@oh-my-pi/pi-ai"; +import type { AssistantMessage } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import type { Rule } from "@oh-my-pi/pi-coding-agent/capability/rule"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; diff --git a/packages/coding-agent/test/sdk-tool-activation.test.ts b/packages/coding-agent/test/sdk-tool-activation.test.ts index a4b8da987..9173cd966 100644 --- a/packages/coding-agent/test/sdk-tool-activation.test.ts +++ b/packages/coding-agent/test/sdk-tool-activation.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { getBundledModel } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { type CreateAgentSessionOptions, diff --git a/packages/coding-agent/test/session-manager-close-race.test.ts b/packages/coding-agent/test/session-manager-close-race.test.ts index bb7fd9229..e5d5b71e1 100644 --- a/packages/coding-agent/test/session-manager-close-race.test.ts +++ b/packages/coding-agent/test/session-manager-close-race.test.ts @@ -24,7 +24,7 @@ */ import { describe, expect, it } from "bun:test"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { MemorySessionStorage, diff --git a/packages/coding-agent/test/session/emit-listener-isolation.test.ts b/packages/coding-agent/test/session/emit-listener-isolation.test.ts index 8001dd1a0..a2efc9875 100644 --- a/packages/coding-agent/test/session/emit-listener-isolation.test.ts +++ b/packages/coding-agent/test/session/emit-listener-isolation.test.ts @@ -6,8 +6,8 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { Agent, type AgentEvent } from "@oh-my-pi/pi-agent-core"; -import { getBundledModel } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; diff --git a/packages/coding-agent/test/shake.test.ts b/packages/coding-agent/test/shake.test.ts index 32c8a5566..3fc31b0d1 100644 --- a/packages/coding-agent/test/shake.test.ts +++ b/packages/coding-agent/test/shake.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, ImageContent, ToolResultMessage } from "@oh-my-pi/pi-ai"; -import { getBundledModel } from "@oh-my-pi/pi-ai/models"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; diff --git a/packages/coding-agent/test/streaming-edit-abort.test.ts b/packages/coding-agent/test/streaming-edit-abort.test.ts index 8d07c789d..47feaf1e8 100644 --- a/packages/coding-agent/test/streaming-edit-abort.test.ts +++ b/packages/coding-agent/test/streaming-edit-abort.test.ts @@ -7,8 +7,9 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; -import { type AssistantMessage, getBundledModel, type StopReason, type ToolCall } from "@oh-my-pi/pi-ai"; +import type { AssistantMessage, StopReason, ToolCall } from "@oh-my-pi/pi-ai"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; diff --git a/packages/coding-agent/test/tiny-title-generator.test.ts b/packages/coding-agent/test/tiny-title-generator.test.ts index 17d728a64..3255f28c1 100644 --- a/packages/coding-agent/test/tiny-title-generator.test.ts +++ b/packages/coding-agent/test/tiny-title-generator.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; +import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai"; import * as ai from "@oh-my-pi/pi-ai"; -import { type Api, type AssistantMessage, getBundledModel, type Model } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { isSubcommand } from "@oh-my-pi/pi-coding-agent/cli-commands"; import { getDefault, getEnumValues, getUi } from "@oh-my-pi/pi-coding-agent/config/settings-schema"; import { TinyTitleDownloadProgressComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tiny-title-download-progress"; diff --git a/packages/coding-agent/test/title-generator.test.ts b/packages/coding-agent/test/title-generator.test.ts index c85e9ef7c..0a3354da1 100644 --- a/packages/coding-agent/test/title-generator.test.ts +++ b/packages/coding-agent/test/title-generator.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; +import type { Api, Model } from "@oh-my-pi/pi-ai"; import * as ai from "@oh-my-pi/pi-ai"; -import { type Api, getBundledModel, type Model } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { generateSessionTitle } from "@oh-my-pi/pi-coding-agent/utils/title-generator"; import { logger } from "@oh-my-pi/pi-utils"; diff --git a/packages/coding-agent/test/tools/approval-mode.test.ts b/packages/coding-agent/test/tools/approval-mode.test.ts index f2fa648c2..4f1bd9459 100644 --- a/packages/coding-agent/test/tools/approval-mode.test.ts +++ b/packages/coding-agent/test/tools/approval-mode.test.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { AgentToolContext } from "@oh-my-pi/pi-agent-core"; -import { getBundledModel } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; diff --git a/packages/coding-agent/test/utilities.ts b/packages/coding-agent/test/utilities.ts index a75b1bd83..0c10adace 100644 --- a/packages/coding-agent/test/utilities.ts +++ b/packages/coding-agent/test/utilities.ts @@ -5,7 +5,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { getBundledModel } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; diff --git a/packages/stats/CHANGELOG.md b/packages/stats/CHANGELOG.md index fd76b5cde..a79b8ef1e 100644 --- a/packages/stats/CHANGELOG.md +++ b/packages/stats/CHANGELOG.md @@ -4,6 +4,7 @@ ### Changed +- Bundled-model lookups (`getBundledModel`, `GeneratedProvider`) now import from the new `@oh-my-pi/pi-catalog` package instead of the `@oh-my-pi/pi-ai` barrel, which no longer re-exports catalog values - The session-sync worker re-enters the host CLI entry (`workerHostEntry()` + `__omp_stats_sync_worker` argv selector) when running inside omp — source, npm bundle, or compiled binary — and keeps loading its own `sync-worker.ts` module directly for standalone `omp-stats`, bun test, and SDK hosts ## [15.1.6] - 2026-05-19 diff --git a/packages/stats/package.json b/packages/stats/package.json index 31aedead3..371880e7b 100644 --- a/packages/stats/package.json +++ b/packages/stats/package.json @@ -38,6 +38,7 @@ }, "dependencies": { "@oh-my-pi/pi-ai": "catalog:", + "@oh-my-pi/pi-catalog": "catalog:", "@oh-my-pi/pi-utils": "catalog:", "@tailwindcss/node": "catalog:", "chart.js": "catalog:", diff --git a/packages/stats/src/db.ts b/packages/stats/src/db.ts index 2720fd801..3e0c807c9 100644 --- a/packages/stats/src/db.ts +++ b/packages/stats/src/db.ts @@ -1,6 +1,8 @@ import { Database } from "bun:sqlite"; import * as fs from "node:fs/promises"; -import { type GeneratedProvider, getBundledModel, type Usage } from "@oh-my-pi/pi-ai"; +import type { Usage } from "@oh-my-pi/pi-ai"; +import type { GeneratedProvider } from "@oh-my-pi/pi-catalog/models"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { getConfigRootDir, getStatsDbPath } from "@oh-my-pi/pi-utils"; import type { AggregatedStats, diff --git a/scripts/ci-release-publish.ts b/scripts/ci-release-publish.ts index c9053ac36..3e983d904 100644 --- a/scripts/ci-release-publish.ts +++ b/scripts/ci-release-publish.ts @@ -77,6 +77,7 @@ function nativeLeafTagFromArgs(argv: readonly string[]): string | null { const nativeLeafTag = nativeLeafTagFromArgs(process.argv.slice(2)); export const packages: PublishPackage[] = [ { dir: "packages/utils", kind: "typescript" }, + { dir: "packages/catalog", kind: "typescript" }, { dir: "packages/ai", kind: "typescript" }, { dir: "packages/natives", kind: "native" }, { dir: "packages/tui", kind: "typescript" }, diff --git a/scripts/install-tests/run-ci.sh b/scripts/install-tests/run-ci.sh index f6976128b..0d5114a97 100755 --- a/scripts/install-tests/run-ci.sh +++ b/scripts/install-tests/run-ci.sh @@ -94,7 +94,7 @@ cp "$natives_pkg_backup" "$ROOT_DIR/packages/natives/package.json" [ "$core_rc" -eq 0 ] || exit "$core_rc" # 3. Pack the remaining workspace packages (natives core handled above). -for pkg in utils hashline ai mnemopi agent tui stats coding-agent; do +for pkg in utils hashline catalog ai mnemopi agent tui stats coding-agent; do ( cd "$ROOT_DIR/packages/$pkg" bun pm pack --destination "$TARBALL_DIR" --quiet >/dev/null @@ -105,6 +105,7 @@ utils_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-pi-utils-*.tgz)" natives_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-pi-natives-[0-9]*.tgz)" natives_leaf_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-pi-natives-"$host_tag"-*.tgz)" hashline_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-hashline-*.tgz)" +catalog_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-pi-catalog-*.tgz)" ai_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-pi-ai-*.tgz)" mnemopi_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-pi-mnemopi-*.tgz)" agent_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-pi-agent-core-*.tgz)" @@ -128,6 +129,7 @@ mkdir -p "$TARBALL_APP_DIR" '@oh-my-pi/pi-natives-$host_tag': '$natives_leaf_tgz', '@oh-my-pi/hashline': '$hashline_tgz', '@oh-my-pi/pi-ai': '$ai_tgz', + '@oh-my-pi/pi-catalog': '$catalog_tgz', '@oh-my-pi/pi-mnemopi': '$mnemopi_tgz', '@oh-my-pi/pi-agent-core': '$agent_tgz', '@oh-my-pi/pi-tui': '$tui_tgz', @@ -137,7 +139,7 @@ mkdir -p "$TARBALL_APP_DIR" require('fs').writeFileSync('package.json', JSON.stringify(pkg, null, 2)); " - bun add "$utils_tgz" "$natives_tgz" "$hashline_tgz" "$ai_tgz" "$mnemopi_tgz" "$agent_tgz" "$tui_tgz" "$stats_tgz" "$coding_agent_tgz" + bun add "$utils_tgz" "$natives_tgz" "$hashline_tgz" "$catalog_tgz" "$ai_tgz" "$mnemopi_tgz" "$agent_tgz" "$tui_tgz" "$stats_tgz" "$coding_agent_tgz" # The platform leaf must arrive through the core's optionalDependencies + # override, not as a direct dependency — assert it landed before smoking so a # resolution regression is distinguishable from a runtime loader bug.