diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 40ee8021f1..5de4260813 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -574,15 +574,24 @@ connexion par clé. ### Ollama Cloud -Ollama Cloud est une version hébergée — et non locale — d'Ollama, compatible avec OpenAI à l'adresse -`https://ollama.com/v1` et accessible avec une clé créée sur -[ollama.com/settings/keys](https://ollama.com/settings/keys). opencodex classe les modèles cloud selon leurs +Ollama Cloud est une version hébergée — et non locale — d'Ollama, à configurer à l'adresse +`https://ollama.com/v1` avec une clé créée sur +[ollama.com/settings/keys](https://ollama.com/settings/keys). opencodex l'atteint via l'API REST +native d'Ollama (`POST /api/chat`) plutôt que via la surface compatible OpenAI, et découvre la +liste des modèles auprès du fournisseur : les nouveaux modèles Ollama Cloud apparaissent sans +modifier la configuration. opencodex classe les modèles cloud selon leurs capacités visuelles, afin que le [service auxiliaire de vision](/fr/guides/sidecars/) n'intervienne que pour les modèles exclusivement textuels. Ces derniers, par exemple `glm-5.2`, `deepseek-v4-pro`, `gpt-oss`, `qwen3-coder`, `minimax-m2.x` et `nemotron-3-*`, figurent dans `noVisionModels` ; les modèles à vision native, comme `kimi-k2.6`, `minimax-m3`, `gemma4`, `qwen3.5` et `gemini-3-flash-preview`, n'y figurent pas. La correspondance tolère les balises `:size` d'Ollama : `gpt-oss` couvre donc `gpt-oss:120b` et `gpt-oss:20b`. +Ollama documente actuellement la sortie structurée comme non prise en charge sur Ollama Cloud. +Pour `ollama-cloud` canonique, opencodex refuse donc les requêtes à sortie structurée +(`text.format`) avec une erreur explicite plutôt que de renvoyer silencieusement une prose libre ; +les points de terminaison locaux et personnalisés `ollama-native` conservent le comportement +natif `format` d'Ollama. + ## 4. Fournisseurs locaux Faites pointer opencodex vers un serveur local compatible OpenAI, généralement avec une clé vide : diff --git a/docs-site/src/content/docs/fr/guides/sidecars.md b/docs-site/src/content/docs/fr/guides/sidecars.md index c5b7a50b1e..763b02ae73 100644 --- a/docs-site/src/content/docs/fr/guides/sidecars.md +++ b/docs-site/src/content/docs/fr/guides/sidecars.md @@ -140,7 +140,6 @@ Un modèle est marqué en texte uniquement par fournisseur : { "providers": { "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] } diff --git a/docs-site/src/content/docs/fr/reference/adapters.md b/docs-site/src/content/docs/fr/reference/adapters.md index ae107baeac..b98394708f 100644 --- a/docs-site/src/content/docs/fr/reference/adapters.md +++ b/docs-site/src/content/docs/fr/reference/adapters.md @@ -22,7 +22,7 @@ interface ProviderAdapter { ## `openai-chat` -**Cibles :** l’API **Chat Completions** d’OpenAI (`POST {baseUrl}/chat/completions` ; un suffixe `/chat/completions` ou `/` est d’abord retiré de `baseUrl`) et tous les fournisseurs compatibles — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (local et cloud), entre autres. +**Cibles :** l’API **Chat Completions** d’OpenAI (`POST {baseUrl}/chat/completions` ; un suffixe `/chat/completions` ou `/` est d’abord retiré de `baseUrl`) et tous les fournisseurs compatibles — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (local), entre autres. **Authentification :** `key` (Bearer). - Convertit les messages internes en rôles OpenAI ; mappe les outils vers `{type:"function", function:{…}}` et `tool_choice` (`auto`/`none`/`required` ou une fonction nommée). @@ -32,6 +32,50 @@ interface ProviderAdapter { - Diffuse `delta.content` (texte), `delta.reasoning_content` (raisonnement) et `delta.tool_calls[]`, et recueille `usage`. - ClinePass utilise le format de passerelle vérifié en conditions réelles `reasoning: { enabled: true, effort }` (ou `{ enabled: false }` lorsque le raisonnement est désactivé). Sa documentation publique d’API ne précise pas encore cette forme de requête. L’adaptateur préserve les niveaux `low`, `medium`, `high`, `xhigh` et `max` demandés, accepte les deltas de raisonnement provenant de `delta.reasoning_content` ou de `delta.reasoning`, demande les données d’utilisation en flux avec `stream_options.include_usage` et lit ces données dans les enveloppes de réponse hors flux. +## `ollama-native` + +**Cibles :** l’**API Chat** native d’Ollama (`POST /api/chat`) plutôt que sa surface compatible +OpenAI. Le fournisseur intégré `ollama-cloud` est sélectionné sur cet adaptateur par le registre ; +il peut aussi être configuré sur un fournisseur Ollama personnalisé ou auto-hébergé distinct avec +`adapter: "ollama-native"`. +**Authentification :** `key` (Bearer) pour les cibles cloud/personnalisées ; aucun identifiant +n’est envoyé aux cibles de boucle locale ou en `authMode: "local"`. + +- **La sélection par le registre est déterminante.** La ligne intégrée `ollama-cloud` conserve + l’URL de base `https://ollama.com/v1` pour la découverte en direct via `/v1/models`, tandis que + l’inférence est normalisée vers `POST https://ollama.com/api/chat`. Un champ `adapter` configuré + est écarté pour cette ligne de fournisseur. L’Ollama local intégré reste sur `openai-chat` ; + choisir `ollama-native` pour un point de terminaison local ou auto-hébergé est une décision + explicite de configuration de fournisseur, détectée par hôte afin qu’une destination non-Ollama + ne soit jamais réécrite silencieusement. +- **Métadonnées des modèles :** `/v1/models` ne porte aucune métadonnée par modèle ; pour Ollama + Cloud canonique, le fournisseur enrichit chaque identifiant découvert via un `POST /api/show` + *borné* (256 KiB par réponse, 8 s par requête, concurrence 4, 48 requêtes, échéance de 12 s pour + toute la phase) afin d’obtenir la véritable fenêtre de contexte et la capacité de vision. La + requête show est de même origine et ne suit jamais une redirection ; un échec dégrade ce seul + modèle sans jamais faire échouer la découverte. +- **Diffusion :** le NDJSON natif d’Ollama. Les deltas de texte et de `message.thinking` sont + transmis dès leur arrivée ; un tour ne se termine que sur un enregistrement terminal + `done: true`, et un `done: false` bufferisé ou un terminal manquant supprime entièrement le + texte partiel et les appels d’outils. +- **Raisonnement :** cartographie le champ natif `think` d’Ollama (`low`/`medium`/`high`/`max`, + plus les booléens), limité à l’échelle annoncée du modèle, et respecte la sémantique de la + sentinelle `__omit__` configurée en amont. +- **Images :** envoyées nativement dans le tableau `images` du message lorsque le modèle prend en + charge la vision ; la vidéo est refusée plutôt que mal envoyée, et les URL d’images distantes ne + sont pas récupérées. +- **Outils :** déclarés dans la forme native d’Ollama ; les appels d’outils diffusés sont des + enregistrements entiers avec des `arguments` objet, et le rejeu des résultats d’outils est + apparié strictement par identifiant d’appel et nom d’outil. `tool_choice: "none"` et `auto` + se comportent normalement ; **`required` ou un choix nommé exact échoue fermement**, car + `/api/chat` d’Ollama n’a aucun champ `tool_choice` pour l’imposer. +- **La sortie structurée est refusée sur Ollama Cloud canonique.** Ollama documente actuellement + la sortie structurée comme non prise en charge sur son Cloud, et Cloud n’applique pas le champ + `format` ; OpenCodex fait donc échouer cette requête plutôt que de renvoyer une prose libre en + réponse à une demande structurée par schéma. Les points de terminaison `ollama-native` locaux et + personnalisés conservent le mappage natif `format` d’Ollama (`json_object` → `"json"`, + `json_schema` → l’objet de schéma). + ## `openai-responses` **Cibles :** l’API **Responses** d’OpenAI. **`passthrough: true`** — transmet tel quel le corps brut de la requête et renvoie le flux de réponse **sans traduction**. diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 822c7ba47c..47029ee45b 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -64,7 +64,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | Champ | Type | Signification | | --- | --- | --- | -| `adapter` | `string` | L'un des `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` (ou alias `azure`). | +| `adapter` | `string` | L'un des `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `ollama-native`, `azure-openai` (ou alias `azure`). | | `baseUrl` | `string` | URL de base de l'API en amont. La plupart des points de terminaison fixes intégrés ignorent une valeur incompatible ; les préréglages de clés protégés contre les collisions préservent une ancienne destination personnalisée portant le même nom. | | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | Cadencement facultatif du démarrage des requêtes sortantes côté client, distinct de l’utilisation, de la facturation et des indicateurs de limitation en amont. Le nombre de requêtes par minute est converti en intervalle régulier ; `minIntervalMs` peut imposer un intervalle plus long. Les limites du fournisseur s’appliquent à tous ses modèles, tandis que les entrées `models` ciblent les identifiants exacts des modèles en amont, par exemple `nvidia/llama-3.1-nemotron-ultra-253b-v1`, et ne peuvent qu’ajouter du délai. L’attente dans la file ne consomme pas le délai d’expiration des en-têtes de réponse en amont. Les requêtes HTTP, Responses WebSocket et les distributions explicites `fetchResponse`/`runTurn` des adaptateurs sont couvertes. | | `responsesPath?` | `string` | Chemin de ressource relatif pour les requêtes d'authentification par clé `openai-responses`. Il doit commencer par `/` et ne contenir aucun schéma, requête ou fragment. | @@ -419,7 +419,6 @@ avec un contexte de `922000` et une entrée maximale de `922000` ; OpenRouter i "defaultModel": "claude-sonnet-4-6" }, "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "apiKey": "${OLLAMA_API_KEY}", "defaultModel": "glm-5.2", diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index b57473114b..f8d13cbe87 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -602,14 +602,22 @@ Cursor is still not shown in key-login lists. ### Ollama Cloud -Ollama Cloud is a hosted (not local) Ollama, OpenAI-compatible at `https://ollama.com/v1` with a key -from [ollama.com/settings/keys](https://ollama.com/settings/keys). opencodex classifies its cloud +Ollama Cloud is a hosted (not local) Ollama. Configure it at `https://ollama.com/v1` with a key +from [ollama.com/settings/keys](https://ollama.com/settings/keys). opencodex reaches it over +Ollama's own REST API (`POST /api/chat`) rather than the OpenAI-compatible surface, and discovers +the live model roster from the provider, so new Ollama Cloud models appear without a config +change. opencodex classifies its cloud lineup by vision capability so the [vision sidecar](/guides/sidecars/) only kicks in for text-only models. Text-only models (e.g. `glm-5.2`, `deepseek-v4-pro`, `gpt-oss`, `qwen3-coder`, `minimax-m2.x`, `nemotron-3-*`) are listed in `noVisionModels`; vision-native models (e.g. `kimi-k2.6`, `minimax-m3`, `gemma4`, `qwen3.5`, `gemini-3-flash-preview`) are not. Matching is tolerant of Ollama's `:size` tags, so `gpt-oss` covers `gpt-oss:120b` and `gpt-oss:20b`. +Ollama currently documents structured outputs as unsupported on Ollama Cloud. For canonical +`ollama-cloud`, opencodex therefore refuses structured-output requests (`text.format`) with a clear +error instead of silently returning unconstrained prose; local and custom `ollama-native` +endpoints keep Ollama's native `format` behavior. + ## 4. Local providers Point opencodex at a local OpenAI-compatible server — usually with a blank key: diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index d92316d796..bb4c4346df 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -178,7 +178,6 @@ A model is marked text-only per provider: { "providers": { "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] } diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index f99bf35aec..51d018f478 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -412,14 +412,21 @@ MCP、画面録画、computer-use はエグゼキューターフックで開か ### Ollama Cloud -Ollama Cloud はホステッド型(ローカルではない)Ollama で、`https://ollama.com/v1` で OpenAI 互換、キーは -[ollama.com/settings/keys](https://ollama.com/settings/keys) で発行されます。opencodex はクラウド +Ollama Cloud はホステッド型(ローカルではない)Ollama です。`https://ollama.com/v1` を設定し、キーは +[ollama.com/settings/keys](https://ollama.com/settings/keys) で発行します。opencodex は OpenAI 互換 +サーフェスではなく Ollama 自身の REST API(`POST /api/chat`)で接続し、モデル一覧はプロバイダーから +動的に取得するため、新しい Ollama Cloud モデルは設定変更なしで現れます。opencodex はクラウド ラインナップをビジョン機能で分類し、[ビジョンサイドカー](/ja/guides/sidecars/)がテキスト専用モデルにのみ 動作するようにします。テキスト専用モデル(例: `glm-5.2`、`deepseek-v4-pro`、`gpt-oss`、`qwen3-coder`、 `minimax-m2.x`、`nemotron-3-*`)は `noVisionModels` に列挙され、ビジョンネイティブモデル(例: `kimi-k2.6`、`minimax-m3`、`gemma4`、`qwen3.5`、`gemini-3-flash-preview`)は含まれません。マッチングは Ollama の `:size` タグに寛容なので `gpt-oss` は `gpt-oss:120b` と `gpt-oss:20b` の両方を含みます。 +Ollama は現在、構造化出力は Ollama Cloud では未対応であるとドキュメントしています。正規の +`ollama-cloud` に対する構造化出力リクエスト(`text.format`)は、自由文を黙って返す代わりに +opencodex が明示的なエラーで拒否します。ローカル / カスタムの `ollama-native` エンドポイントは +Ollama ネイティブの `format` 動作を保持します。 + ## 4. ローカルプロバイダー opencodex をローカルの OpenAI 互換サーバーに向けてください — 通常は空キーで使います: diff --git a/docs-site/src/content/docs/ja/guides/sidecars.md b/docs-site/src/content/docs/ja/guides/sidecars.md index 7a44113a5d..48af198fe3 100644 --- a/docs-site/src/content/docs/ja/guides/sidecars.md +++ b/docs-site/src/content/docs/ja/guides/sidecars.md @@ -119,7 +119,6 @@ OpenAI 実行経路、ダッシュボード、管理 API は `gpt-5.4-mini` を { "providers": { "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] } diff --git a/docs-site/src/content/docs/ja/reference/adapters.md b/docs-site/src/content/docs/ja/reference/adapters.md index f3f7b65452..5e9427de59 100644 --- a/docs-site/src/content/docs/ja/reference/adapters.md +++ b/docs-site/src/content/docs/ja/reference/adapters.md @@ -22,7 +22,7 @@ interface ProviderAdapter { ## `openai-chat` **対象:** OpenAI **Chat Completions**(`POST {baseUrl}/chat/completions`)および互換プロバイダー -— xAI、Kimi、DeepSeek、GLM、Groq、OpenRouter、Ollama(ローカルとクラウド)など。 +— xAI、Kimi、DeepSeek、GLM、Groq、OpenRouter、Ollama(ローカル)など。 **認証:** `key`(Bearer)。 - 内部メッセージを OpenAI role に変換し、ツールは `{type:"function", function:{…}}` と @@ -41,6 +41,44 @@ interface ProviderAdapter { `xhigh`、`max` tier をそのまま保持し、`delta.reasoning_content` または `delta.reasoning` を reasoning delta として扱い、`stream_options.include_usage` でストリーム usage を要求し、非ストリームのレスポンス envelope からも usage を読み取ります。 +## `ollama-native` + +**対象:** OpenAI 互換サーフェスではなく、Ollama 自身の **Chat API**(`POST /api/chat`)。 +組み込みの `ollama-cloud` プロバイダーはこの adapter にレジストリで選択され、別名のカスタム / +セルフホスト Ollama プロバイダーに `adapter: "ollama-native"` を設定して使うこともできます。 +**認証:** cloud / カスタム宛先は `key`(Bearer)。loopback または `authMode: "local"` +の宛先には資格情報を送りません。 + +- **レジストリ選択が実質的に効きます。** 組み込みの `ollama-cloud` 行は `/v1/models` による + ライブ探索のため `https://ollama.com/v1` を維持しつつ、推論は + `POST https://ollama.com/api/chat` に正規化されます。このプロバイダー行では設定した + `adapter` は破棄されます。通常の組み込みローカル Ollama は `openai-chat` のままです。 + ローカル / セルフホスト宛先に `ollama-native` を選ぶのは、プロバイダー設定での明示的な判断 + であり、ホストで判定されるため非 Ollama 宛先が黙って書き換えられることはありません。 +- **モデルメタデータ:** `/v1/models` にはモデルごとのメタデータがないため、正規の Ollama + Cloud では *上限付き* の `POST /api/show`(応答 256 KiB、1 要求 8 秒、並列 4、48 要求、 + フェーズ全体に 12 秒の締切)で発見された各 id を補完し、実際の context window と vision + 対応を取得します。show 要求は同一オリジンでリダイレクトを追わず、失敗してもその 1 モデル + だけが劣化し、発見自体は失敗しません。 +- **ストリーミング:** Ollama ネイティブの NDJSON。テキストと `message.thinking` の delta を + 到着順に転送し、`done: true` の終端レコードでのみターンを完了します。buffer された + `done: false` や終端の欠落では部分的なテキストも tool call も一切出力しません。 +- **Reasoning:** Ollama ネイティブの `think` フィールド(`low` / `medium` / `high` / `max` と + boolean)に対応し、モデルの公開 ladder へクランプし、上流で設定された `__omit__` sentinel の + 意味論に従います。 +- **画像:** vision 対応モデルではメッセージの `images` 配列でネイティブ送信します。video は + 誤送信ではなく拒否され、リモート画像 URL の取得は行いません。 +- **ツール:** Ollama のネイティブ形状で宣言し、ストリームされる tool call は `arguments` が + オブジェクトの whole-call レコード、tool result のリプレイは call id と tool 名で厳密に対応 + 付けられます。`tool_choice: "none"` と `auto` は通常どおりです。**`required` や名前指定は + fail closed** です。Ollama の `/api/chat` にはそれを強制できる `tool_choice` フィールドが + ありません。 +- **構造化出力は正規の Ollama Cloud では拒否されます。** Ollama は Cloud で構造化出力が未対応 + であると現在ドキュメントしており、Cloud は `format` フィールドを強制しません。そのため + OpenCodex は、schema 指定の要求に対して自由文を返すのではなく、要求を閉じて失敗させます。 + ローカル / カスタムの `ollama-native` エンドポイントは Ollama ネイティブの `format` マッピング + (`json_object` → `"json"`、`json_schema` → schema オブジェクトそのもの)を保持します。 + ## `openai-responses` **対象:** OpenAI **Responses API**。**`passthrough: true`** — 通常は元のリクエストとレスポンスをそのまま渡し、ルーティング先ゲートウェイに必要な限定的な互換変換だけを適用します。 diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index f1210892a5..b51e8a2e32 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -54,7 +54,7 @@ account を削除しても mapping は保持され、同じ id を再追加す |フィールド |タイプ |意味 | | --- | --- | --- | -| `adapter` | `string` | `openai-chat`、`openai-responses`、`anthropic`、`google`、`kiro`、`cursor`、`azure-openai` (または別名 `azure`) のいずれか。 | +| `adapter` | `string` | `openai-chat`、`openai-responses`、`anthropic`、`google`、`kiro`、`cursor`、`ollama-native`、`azure-openai` (または別名 `azure`) のいずれか。 | | `baseUrl` | `string` |アップストリーム API のベース URL。ほとんどの組み込み固定エンドポイントは不一致を無視します。衝突安全キー プリセットは、古い同じ名前のカスタム宛先を保持します。 | | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | 上流の使用量、請求、レート制限表示とは別の、クライアント側の送信開始間隔調整です。プロバイダー制限は全モデルに適用され、`models` は上流の正確なモデル ID に一致し、遅延を増やす場合のみ有効です。キュー待機は応答ヘッダーのタイムアウトを消費しません。HTTP、Responses WebSocket、明示的なアダプターの `fetchResponse`/`runTurn` 送信を対象にします。 | | `responsesPath?` | `string` |キー認証 `openai-responses` リクエストの相対リソース パス。 `/` で始まり、スキーム、クエリ、またはフラグメントが含まれていない必要があります。 | @@ -337,7 +337,6 @@ OpenRouter は、複数の推論プロバイダーを通じて 1 つのモデル "defaultModel": "claude-sonnet-4-6" }, "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "apiKey": "${OLLAMA_API_KEY}", "defaultModel": "glm-5.2", diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index b24e17f3fc..3c28be0a59 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -403,14 +403,21 @@ model discovery는 이 실험적 어댑터에서 활성화되어 있으며, Curs ### Ollama Cloud -Ollama Cloud는 호스팅형(로컬이 아님) Ollama로, `https://ollama.com/v1`에서 OpenAI 호환이며 키는 -[ollama.com/settings/keys](https://ollama.com/settings/keys)에서 발급받습니다. opencodex는 클라우드 +Ollama Cloud는 호스팅형(로컬이 아님) Ollama입니다. `https://ollama.com/v1`으로 설정하고 키는 +[ollama.com/settings/keys](https://ollama.com/settings/keys)에서 발급받습니다. opencodex는 OpenAI 호환 +표면이 아니라 Ollama 자체 REST API(`POST /api/chat`)로 연결하며, 모델 목록을 공급자에서 직접 +발견하므로 새 Ollama Cloud 모델이 설정 변경 없이 나타납니다. opencodex는 클라우드 라인업을 비전 기능에 따라 분류하여 [비전 사이드카](/ko/guides/sidecars/)가 텍스트 전용 모델에만 작동하도록 합니다. 텍스트 전용 모델(예: `glm-5.2`, `deepseek-v4-pro`, `gpt-oss`, `qwen3-coder`, `minimax-m2.x`, `nemotron-3-*`)은 `noVisionModels`에 나열되며, 비전 네이티브 모델(예: `kimi-k2.6`, `minimax-m3`, `gemma4`, `qwen3.5`, `gemini-3-flash-preview`)은 포함되지 않습니다. 매칭은 Ollama의 `:size` 태그에 관대하므로 `gpt-oss`는 `gpt-oss:120b`와 `gpt-oss:20b`를 모두 포괄합니다. +Ollama는 현재 구조화 출력이 Ollama Cloud에서 지원되지 않는다고 문서화하고 있습니다. 정식 +`ollama-cloud`에 대한 구조화 출력 요청(`text.format`)은 opencodex가 자유 서술을 조용히 돌려주는 +대신 명확한 오류로 거부합니다. 로컬 / 커스텀 `ollama-native` 엔드포인트는 Ollama의 네이티브 +`format` 동작을 유지합니다. + ## 4. 로컬 프로바이더 opencodex를 로컬 OpenAI 호환 서버로 향하게 하세요 — 보통은 빈 키와 함께 사용합니다: diff --git a/docs-site/src/content/docs/ko/guides/sidecars.md b/docs-site/src/content/docs/ko/guides/sidecars.md index e75e0521e5..2aab84f438 100644 --- a/docs-site/src/content/docs/ko/guides/sidecars.md +++ b/docs-site/src/content/docs/ko/guides/sidecars.md @@ -121,7 +121,6 @@ OpenAI 실행 경로, Dashboard, 관리 API는 `gpt-5.4-mini`를 폴백으로 { "providers": { "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] } diff --git a/docs-site/src/content/docs/ko/reference/adapters.md b/docs-site/src/content/docs/ko/reference/adapters.md index 657089f218..dd81eddb41 100644 --- a/docs-site/src/content/docs/ko/reference/adapters.md +++ b/docs-site/src/content/docs/ko/reference/adapters.md @@ -26,7 +26,7 @@ interface ProviderAdapter { ## `openai-chat` **대상:** OpenAI **Chat Completions**(`POST {baseUrl}/chat/completions`)와 모든 호환 프로바이더 -— xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama(로컬 및 클라우드) 등. +— xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama(로컬) 등. **인증:** `key`(Bearer). - 내부 메시지를 OpenAI role로 변환하고, 툴은 `{type:"function", function:{…}}`과 @@ -47,6 +47,40 @@ interface ProviderAdapter { 유지하고, `delta.reasoning_content` 또는 `delta.reasoning`을 reasoning delta로 처리하며, `stream_options.include_usage`로 스트림 usage를 요청하고 비스트림 응답 envelope에서도 usage를 읽습니다. +## `ollama-native` + +**대상:** OpenAI 호환 표면이 아니라 Ollama 자체의 **Chat API**(`POST /api/chat`). 내장 +`ollama-cloud` 공급자는 이 어댑터로 레지스트리에서 선택되며, 별도 이름의 커스텀/셀프호스팅 +Ollama 공급자에 `adapter: "ollama-native"`로 설정할 수도 있습니다. +**인증:** cloud/커스텀 대상은 `key`(Bearer). loopback 또는 `authMode: "local"` 대상에는 +자격 증명을 보내지 않습니다. + +- **레지스트리 선택이 실질적입니다.** 내장 `ollama-cloud` 행은 `/v1/models` 라이브 발견을 위해 + `https://ollama.com/v1` 기준 URL을 유지하면서 추론은 `POST https://ollama.com/api/chat`으로 + 정규화됩니다. 이 공급자 행에서는 설정한 `adapter`가 버려집니다. 일반 내장 로컬 Ollama는 + `openai-chat`을 유지하며, 로컬/셀프호스팅 대상에 `ollama-native`를 선택하는 것은 명시적인 + 공급자 구성 결정이고 호스트로 판별되므로 비(非)Ollama 대상이 조용히 재작성되지 않습니다. +- **모델 메타데이터:** `/v1/models`에는 모델별 메타데이터가 없으므로, 정식 Ollama Cloud에서는 + *제한된* `POST /api/show`(응답 256 KiB, 요청당 8초, 동시성 4, 48요청, 전체 단계 12초 마감)로 + 발견된 각 id를 보완해 실제 context window와 vision 지원을 채웁니다. show 요청은 동일 + 오리진이며 리다이렉트를 따르지 않고, 실패해도 해당 모델만 저하되고 발견 자체는 실패하지 않습니다. +- **스트리밍:** Ollama 네이티브 NDJSON. 텍스트와 `message.thinking` delta를 도착 즉시 전달하고, + `done: true` 터미널 레코드에서만 턴을 완료합니다. 버퍼된 `done: false`나 누락된 터미널은 부분 + 텍스트와 tool call을 전부 억제합니다. +- **Reasoning:** Ollama 네이티브 `think` 필드(`low`/`medium`/`high`/`max` 및 불리언)로 매핑되고 + 모델의 공개 ladder로 클램프되며, 업스트림에서 구성한 `__omit__` sentinel 의미를 따릅니다. +- **이미지:** vision 지원 모델이면 메시지의 `images` 배열로 네이티브 전송됩니다. video는 잘못 + 보내지 않고 거부하며, 원격 이미지 URL은 가져오지 않습니다. +- **도구:** Ollama 네이티브 형태로 선언되고, 스트림 tool call은 `arguments`가 객체인 whole-call + 레코드이며 tool result 리플레이는 call id와 tool 이름으로 엄격히 짝지어집니다. + `tool_choice: "none"`과 `auto`는 정상 동작합니다. **`required`나 정확한 이름 지정은 fail + closed**입니다. Ollama의 `/api/chat`에는 이를 강제할 `tool_choice` 필드가 없기 때문입니다. +- **구조화 출력은 정식 Ollama Cloud에서 거부됩니다.** Ollama는 현재 Cloud에서 구조화 출력을 + 지원하지 않는다고 문서화하고 있으며 Cloud는 `format` 필드를 강제하지 않습니다. 따라서 + OpenCodex는 스키마가 지정된 요청에 자유 서술을 돌려주는 대신 요청을 닫고 실패시킵니다. 로컬 / + 커스텀 `ollama-native` 엔드포인트는 Ollama 네이티브 `format` 매핑(`json_object` → `"json"`, + `json_schema` → schema 객체 자체)을 유지합니다. + ## `openai-responses` **대상:** OpenAI **Responses API**. **`passthrough: true`** — 일반적으로 원본 요청과 응답을 diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index ccacb0a94f..b9eb746726 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -54,7 +54,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | 필드 | 타입 | 의미 | | --- | --- | --- | -| `adapter` | `string` | `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` 중 하나이며, `azure`는 별칭입니다. | +| `adapter` | `string` | `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `ollama-native`, `azure-openai` 중 하나이며, `azure`는 별칭입니다. | | `baseUrl` | `string` | 상위 API 기본 URL입니다. 대부분의 내장 고정 엔드포인트는 불일치를 무시합니다. 충돌 안전 키 프리셋은 같은 이름의 이전 사용자 지정 목적지를 보존합니다. | | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | 업스트림 사용량, 과금, rate-limit 지표와 별개인 선택적 클라이언트 측 아웃바운드 요청 시작 속도 조절입니다. Provider 제한은 모든 모델에 적용되고 `models` 항목은 정확한 업스트림 모델 ID와 일치하며 지연을 더 늘릴 때만 적용됩니다. 큐 대기는 응답 헤더 타임아웃을 소모하지 않습니다. HTTP, Responses WebSocket, 명시적 어댑터 `fetchResponse`/`runTurn` 전송을 포함합니다. | | `responsesPath?` | `string` | 키 인증 `openai-responses` 요청의 상대 리소스 경로입니다. 반드시 `/`로 시작해야 하며 스킴, query, fragment를 포함하면 안 됩니다. | @@ -338,7 +338,6 @@ OpenRouter는 하나의 모델을 여러 추론 공급자로 제공할 수 있 "defaultModel": "claude-sonnet-4-6" }, "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "apiKey": "${OLLAMA_API_KEY}", "defaultModel": "glm-5.2", diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 8919cd428b..dd566be5be 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -26,7 +26,7 @@ then turns the events into Responses SSE. ## `openai-chat` **Targets:** OpenAI **Chat Completions** (`POST {baseUrl}/chat/completions`; a trailing `/chat/completions` or `/` on `baseUrl` is stripped first) and every compatible -provider — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (local & cloud), and more. +provider — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (local), and more. **Auth:** `key` (Bearer). - Converts internal messages to OpenAI roles; maps tools to `{type:"function", function:{…}}` and @@ -47,6 +47,44 @@ provider — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (local & cloud), tiers, accepts reasoning deltas from either `delta.reasoning_content` or `delta.reasoning`, requests streamed usage with `stream_options.include_usage`, and reads usage from non-stream response envelopes. +## `ollama-native` + +**Targets:** Ollama's own **Chat API** (`POST /api/chat`) rather than its OpenAI-compatible +surface. The built-in `ollama-cloud` provider is registry-selected onto this adapter; it can also +be configured on a separately named custom or self-hosted Ollama provider with +`adapter: "ollama-native"`. +**Auth:** `key` (Bearer) for cloud/custom endpoints; no credential is sent to loopback or +`authMode: "local"` targets. + +- **Registry selection is load-bearing.** The built-in `ollama-cloud` row keeps the base URL + `https://ollama.com/v1` for `/v1/models` live discovery, while inference is normalized onto + `POST https://ollama.com/api/chat`. A config-level `adapter` is discarded for that provider row. + Ordinary built-in local Ollama stays on `openai-chat`; choosing `ollama-native` for a local or + self-hosted endpoint is an explicit provider-configuration decision, detected by host so a + non-Ollama destination is never silently rewritten. +- **Model metadata:** `/v1/models` carries no per-model metadata, so for canonical Ollama Cloud the + adapter's provider enriches each discovered id through a *bounded* `POST /api/show` (256 KiB per + response, 8 s per request, concurrency 4, 48 requests, a 12 s deadline for the whole phase) to fill + the true context window and vision capability. The show request is same-origin and never follows a + redirect; failures degrade that one model and never fail discovery. +- **Streaming:** Ollama's native NDJSON. Text and `message.thinking` deltas are forwarded as they + arrive; a turn completes only on a `done: true` terminal record, and buffered `done: false` or a + missing terminal suppresses partial text and tool calls entirely. +- **Reasoning:** maps onto Ollama's native `think` field (`low`/`medium`/`high`/`max`, plus + booleans), clamped to the model's advertised ladder, and honours the `__omit__` sentinel semantics + upstream configures. +- **Images:** sent natively in the message `images` array where the model is vision-capable; video + is refused rather than mis-sent, and remote image URLs are not fetched. +- **Tools:** declared in Ollama's native shape, streamed tool calls are whole-call records with + object-valued `arguments`, and tool-result replay is paired strictly by call id and tool name. + `tool_choice: "none"` and `auto` behave normally; **`required` or an exact named choice fails + closed**, because Ollama's `/api/chat` has no `tool_choice` field to enforce it with. +- **Structured output is refused on canonical Ollama Cloud.** Ollama currently documents structured + outputs as unsupported on its Cloud, and Cloud does not enforce the `format` field, so OpenCodex + fails that request closed rather than returning unconstrained prose in answer to a schema-shaped + request. Local and custom `ollama-native` endpoints keep Ollama's native `format` mapping + (`json_object` → `"json"`, `json_schema` → the schema object). + ## `openai-responses` **Targets:** the OpenAI **Responses API**. **`passthrough: true`** — normally forwards the raw request diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index f70ede7ea3..8840acf900 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -64,7 +64,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | Field | Type | Meaning | | --- | --- | --- | -| `adapter` | `string` | One of `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` (or alias `azure`). | +| `adapter` | `string` | One of `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `ollama-native`, `azure-openai` (or alias `azure`). | | `baseUrl` | `string` | Upstream API base URL. Most built-in fixed endpoints ignore a mismatch; collision-safe key presets preserve an older same-named custom destination. | | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | Optional client-side outbound request-start pacing, separate from upstream usage, billing, and rate-limit indicators. RPM is converted to an even interval; `minIntervalMs` may impose a longer interval. Provider limits apply across all models, while `models` entries use exact upstream model IDs (for example `nvidia/llama-3.1-nemotron-ultra-253b-v1`) and can only add delay. Queue waits do not consume the upstream response-header timeout. HTTP, Responses WebSocket, and explicit adapter `fetchResponse`/`runTurn` dispatches are covered. | | `upstreamHttpVersion?` | `"auto" \| "http1.1" \| "h1" \| "http2" \| "h2"` | Pin the HTTP version used for upstream requests to this provider. Defaults to `auto`, which lets Bun negotiate. An explicit pin requires an HTTPS target and fails locally when it cannot be honored. Set `http1.1` when a provider's HTTP/2 SSE stream stalls instead of delivering events — the symptom is a long-running streaming request that produces nothing and eventually times out. For Cursor, `http1.1`/`h1` selects its `RunSSE` + `BidiAppend` compatibility transport for inference and also pins live model discovery. Management `POST`/`PATCH` accept `null` to clear it back to `auto`. | @@ -604,7 +604,6 @@ ids with context `922000` and max input `922000`; OpenRouter seeds `openai/gpt-5 "defaultModel": "claude-sonnet-4-6" }, "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "apiKey": "${OLLAMA_API_KEY}", "defaultModel": "glm-5.2", diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 1dfe171a58..b99eb88ec2 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -447,9 +447,12 @@ MCP, запись экрана и computer-use доступны как хуки ### Ollama Cloud -Ollama Cloud — это размещённая в облаке (не локальная) Ollama, OpenAI-совместимая по адресу -`https://ollama.com/v1`, с ключом со страницы -[ollama.com/settings/keys](https://ollama.com/settings/keys). opencodex классифицирует её облачную +Ollama Cloud — это размещённая в облаке (не локальная) Ollama. Укажите адрес +`https://ollama.com/v1` и ключ со страницы +[ollama.com/settings/keys](https://ollama.com/settings/keys). opencodex обращается к ней через +собственный REST API Ollama (`POST /api/chat`), а не через OpenAI-совместимую поверхность, и +получает список моделей от провайдера, поэтому новые модели Ollama Cloud появляются без +изменения конфигурации. opencodex классифицирует её облачную линейку по поддержке изображений, чтобы [vision-сайдкар](/ru/guides/sidecars/) включался только для текстовых моделей. Текстовые модели (например, `glm-5.2`, `deepseek-v4-pro`, `gpt-oss`, `qwen3-coder`, `minimax-m2.x`, `nemotron-3-*`) перечислены в `noVisionModels`; модели с нативной @@ -457,6 +460,11 @@ Ollama Cloud — это размещённая в облаке (не локал `gemini-3-flash-preview`) — нет. Сопоставление терпимо к тегам Ollama вида `:size`, поэтому `gpt-oss` покрывает и `gpt-oss:120b`, и `gpt-oss:20b`. +Ollama в документации указывает, что структурированный вывод сейчас не поддерживается на Ollama +Cloud. Поэтому для канонического `ollama-cloud` opencodex отклоняет такие запросы +(`text.format`) явной ошибкой, а не молча возвращает свободную прозу; локальные и пользовательские +`ollama-native` конечные точки сохраняют нативное поведение `format` Ollama. + ## 4. Локальные провайдеры Направьте opencodex на локальный OpenAI-совместимый сервер — обычно с пустым ключом: diff --git a/docs-site/src/content/docs/ru/guides/sidecars.md b/docs-site/src/content/docs/ru/guides/sidecars.md index fed573bd8e..bc2a691a67 100644 --- a/docs-site/src/content/docs/ru/guides/sidecars.md +++ b/docs-site/src/content/docs/ru/guides/sidecars.md @@ -133,7 +133,6 @@ SSE-событие `response.failed`. { "providers": { "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] } diff --git a/docs-site/src/content/docs/ru/reference/adapters.md b/docs-site/src/content/docs/ru/reference/adapters.md index 84398da179..ecff865ffc 100644 --- a/docs-site/src/content/docs/ru/reference/adapters.md +++ b/docs-site/src/content/docs/ru/reference/adapters.md @@ -28,7 +28,7 @@ interface ProviderAdapter { ## `openai-chat` **Назначение:** OpenAI **Chat Completions** (`POST {baseUrl}/chat/completions`) и все совместимые -провайдеры — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (локально и в облаке) и другие. +провайдеры — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (локально) и другие. **Аутентификация:** `key` (Bearer). - Преобразует внутренние сообщения в роли OpenAI; инструменты отображаются в @@ -51,6 +51,47 @@ interface ProviderAdapter { `delta.reasoning_content` или `delta.reasoning`, запрашивает usage потока через `stream_options.include_usage` и читает usage из envelope нестримингового ответа. +## `ollama-native` + +**Цели:** собственный **Chat API** Ollama (`POST /api/chat`) вместо его OpenAI-совместимой +поверхности. Встроенный провайдер `ollama-cloud` выбирается на этот адаптер реестром; его также +можно настроить для отдельного пользовательского или self-hosted провайдера Ollama с +`adapter: "ollama-native"`. +**Аутентификация:** `key` (Bearer) для cloud/пользовательских адресов; учётные данные не +отправляются на loopback-цели и при `authMode: "local"`. + +- **Выбор через реестр имеет решающее значение.** Встроенная строка `ollama-cloud` сохраняет + базовый URL `https://ollama.com/v1` для живого обнаружения через `/v1/models`, тогда как вывод + нормализуется на `POST https://ollama.com/api/chat`. Настроенный уровень `adapter` для этой + строки провайдера отбрасывается. Обычный встроенный локальный Ollama остаётся на `openai-chat`; + выбор `ollama-native` для локального или self-hosted адреса — это явное решение конфигурации + провайдера, определяемое по хосту, так что не-Ollama назначение никогда не переписывается молча. +- **Метаданные моделей:** `/v1/models` не несёт метаданных по моделям, поэтому для канонического + Ollama Cloud провайдер дополняет каждый обнаружённый id через *ограниченный* `POST /api/show` + (256 KiB на ответ, 8 с на запрос, параллельность 4, 48 запросов, дедлайн 12 с на всю фазу), чтобы + получить реальное окно контекста и поддержку зрения. Запрос show — того же источника и никогда + не следует за перенаправлением; сбой деградирует только эту модель и не ломает обнаружение. +- **Стриминг:** нативный NDJSON Ollama. Дельты текста и `message.thinking` пересылаются по мере + поступления; ход завершается только по терминальной записи `done: true`, а буферизованный + `done: false` или отсутствующий терминал полностью подавляют частичный текст и вызовы + инструментов. +- **Reasoning:** отображается на нативное поле `think` Ollama (`low`/`medium`/`high`/`max`, плюс + булевы значения), зажимается до заявленной лестницы модели и соблюдает семантику sentinel + `__omit__`, настроенную выше по стеку. +- **Изображения:** отправляются нативно в массиве `images` сообщения, если модель поддерживает + зрение; видео отклоняется, а не отправляется неверно, удалённые URL изображений не загружаются. +- **Инструменты:** объявляются в нативной форме Ollama; стриминговые вызовы инструментов — + цельные записи с `arguments` в виде объекта, а повтор результатов инструментов строго + сопоставляется по id вызова и имени инструмента. `tool_choice: "none"` и `auto` работают + обычно; **`required` или точное именованное значение завершается ошибкой**, потому что + `/api/chat` Ollama не имеет поля `tool_choice`, которым его можно было бы навязать. +- **Структурированный вывод отклоняется на каноническом Ollama Cloud.** Ollama в документации + указывает, что структурированные выходные данные сейчас не поддерживаются в его Cloud, и Cloud не + соблюдает поле `format`, поэтому OpenCodex закрывает такой запрос с ошибкой, а не возвращает + свободную прозу в ответ на запрос с указанием схемы. Локальные и пользовательские + `ollama-native` конечные точки сохраняют нативное отображение `format` Ollama + (`json_object` → `"json"`, `json_schema` → сам объект схемы). + ## `openai-responses` **Назначение:** OpenAI **Responses API**. **`passthrough: true`** — пересылает исходное тело diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index c415517074..94334dae06 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -67,7 +67,7 @@ cross-route credential fallback не существует. Строки API GPT- | Поле | Тип | Значение | | --- | --- | --- | -| `adapter` | `string` | Один из `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` (или alias `azure`). | +| `adapter` | `string` | Один из `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `ollama-native`, `azure-openai` (или alias `azure`). | | `baseUrl` | `string` | Базовый URL API upstream'а. Большинство built-in fixed-endpoint'ов игнорируют несовпадение; collision-safe key-preset'ы сохраняют старый custom destination с тем же именем. | | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | Опциональное клиентское выравнивание начала исходящих запросов, отдельное от учёта использования, биллинга и индикаторов rate limit апстрима. Лимит провайдера действует на все модели, а `models` сопоставляется с точными ID моделей апстрима и может только увеличить задержку. Ожидание очереди не расходует таймаут заголовков ответа. Поддерживаются HTTP, Responses WebSocket и явные вызовы адаптеров `fetchResponse`/`runTurn`. | | `responsesPath?` | `string` | Relative resource path для key-auth запросов `openai-responses`. Должен начинаться с `/` и не может содержать scheme, query или fragment. | @@ -423,7 +423,6 @@ Pool/Direct рекламирует `922000`; синхронизированны "defaultModel": "claude-sonnet-4-6" }, "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "apiKey": "${OLLAMA_API_KEY}", "defaultModel": "glm-5.2", diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index 15e2ab3cf4..4559133d43 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -622,8 +622,11 @@ anahtar girişi listelerinde hala gösterilmez. ### Ollama Cloud Ollama Cloud, [ollama.com/settings/keys](https://ollama.com/settings/keys) -adresinden alınan bir anahtarla `https://ollama.com/v1` adresinde OpenAI uyumlu -barındırılan (yerel olmayan) bir Ollama'dır. opencodex, bulut serisini vizyon +adresinden alınan bir anahtarla `https://ollama.com/v1` adresinde yapılandırılan, +barındırılan (yerel olmayan) bir Ollama'dır. opencodex ona OpenAI uyumlu yüzey +yerine Ollama'nın kendi REST API'si (`POST /api/chat`) üzerinden erişir ve model +listesini sağlayıcıdan keşfeder; böylece yeni Ollama Cloud modelleri yapılandırma +değişikliği olmadan görünür. opencodex, bulut serisini vizyon yeteneğine göre sınıflandırır, böylece [vizyon sidecar'ı](/tr/guides/sidecars/) yalnızca salt metin modeller için devreye girer. Salt metin modeller (örneğin `glm-5.2`, `deepseek-v4-pro`, `gpt-oss`, `qwen3-coder`, `minimax-m2.x`, @@ -633,6 +636,11 @@ yalnızca salt metin modeller için devreye girer. Salt metin modeller (örneği etiketlerine toleranslıdır, bu nedenle `gpt-oss`, `gpt-oss:120b` ve `gpt-oss:20b`'yi kapsar. +Ollama şu anda yapılandırılmış çıktıyı Ollama Cloud'da desteklemediğini belgeliyor. Kanonik +`ollama-cloud` için opencodex, yapılandırılmış çıktı isteklerini (`text.format`) serbest metni +sessizce döndürmek yerine net bir hatayla reddeder; yerel ve özel `ollama-native` uç noktaları +Ollama'nın yerel `format` davranışını korur. + ## 4. Yerel sağlayıcılar opencodex'i yerel bir OpenAI uyumlu sunucuya yönlendirin — genellikle boş bir diff --git a/docs-site/src/content/docs/tr/guides/sidecars.md b/docs-site/src/content/docs/tr/guides/sidecars.md index 1057ab70a7..5a46fbabc3 100644 --- a/docs-site/src/content/docs/tr/guides/sidecars.md +++ b/docs-site/src/content/docs/tr/guides/sidecars.md @@ -171,7 +171,6 @@ Bir model, sağlayıcı başına salt metin olarak işaretlenir: { "providers": { "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] } diff --git a/docs-site/src/content/docs/tr/reference/adapters.md b/docs-site/src/content/docs/tr/reference/adapters.md index 42db6d20d4..b872a455d2 100644 --- a/docs-site/src/content/docs/tr/reference/adapters.md +++ b/docs-site/src/content/docs/tr/reference/adapters.md @@ -30,8 +30,7 @@ olayları Responses SSE'ye dönüştürür. **Hedefler:** OpenAI **Chat Completions** (`POST {baseUrl}/chat/completions`; `baseUrl` üzerindeki sondaki `/chat/completions` veya `/` önce kaldırılır) ve -her uyumlu sağlayıcı — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (yerel -ve bulut) ve daha fazlası. +her uyumlu sağlayıcı — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (yerel) ve daha fazlası. **Kimlik Doğrulama:** `key` (Bearer). - Dahili mesajları OpenAI rollerine dönüştürür; araçları `{type:"function", @@ -56,6 +55,46 @@ ve bulut) ve daha fazlası. yürütme farklarını kabul eder, `stream_options.include_usage` ile akışlı kullanım ister ve akışsız yanıt zarflarından kullanımı okur. +## `ollama-native` + +**Hedefler:** OpenAI uyumlu yüzey yerine Ollama'nın kendi **Chat API'si** (`POST /api/chat`). +Yerleşik `ollama-cloud` sağlayıcısı kayıt defteri tarafından bu adaptöre seçilir; ayrıca ayrı adlı +özel veya kendi kendine barındırılan bir Ollama sağlayıcısında `adapter: "ollama-native"` ile +yapılandırılabilir. +**Kimlik Doğrulama:** bulut/özel hedefler için `key` (Bearer). Loopback veya `authMode: "local"` +hedeflerine kimlik bilgisi gönderilmez. + +- **Kayıt defteri seçimi belirleyicidir.** Yerleşik `ollama-cloud` satırı, `/v1/models` canlı + keşfi için `https://ollama.com/v1` temel URL'sini korurken çıkarım + `POST https://ollama.com/api/chat` üzerine normalleştirilir. Sağlayıcı satırındaki yapılandırılmış + `adapter` değeri atılır. Sıradan yerleşik yerel Ollama `openai-chat` üzerinde kalır; yerel veya + self-hosted bir hedef için `ollama-native` seçmek açık bir sağlayıcı yapılandırma kararıdır ve + ana bilgisayara göre belirlenir, böylece Ollama olmayan bir hedef hiçbir zaman sessizce + yeniden yazılmaz. +- **Model meta verileri:** `/v1/models` model başına meta veri taşımaz; bu yüzden kanonik Ollama + Cloud için sağlayıcı, keşfedilen her kimliği *sınırlı* bir `POST /api/show` ile zenginleştirir + (yanıt başına 256 KiB, istek başına 8 sn, eşzamanlılık 4, 48 istek, tüm aşama için 12 sn süre) ve + gerçek bağlam penceresi ile vision yeteneğini doldurur. show isteği aynı kaynaktadır ve asla bir + yönlendirmeyi izlemez; hata yalnızca o modeli düşürür, keşfi asla bozmaz. +- **Akış:** Ollama'nın yerel NDJSON'u. Metin ve `message.thinking` delta'ları geldikçe iletilir; + bir tur yalnızca `done: true` terminal kaydında tamamlanır ve tamponlanmış `done: false` ya da + eksik terminal, kısmi metni ve araç çağrılarını tamamen bastırır. +- **Reasoning:** Ollama'nın yerel `think` alanına (`low`/`medium`/`high`/`max` ve booleans) + eşlenir, modelin duyurulan merdivenine kırpılır ve üst katmanda yapılandırılan `__omit__` + sentinel semantiğine uyar. +- **Görseller:** model vision destekliyorsa mesajın `images` dizisinde yerel olarak gönderilir; + video yanlış gönderilmek yerine reddedilir ve uzak görsel URL'leri alınmaz. +- **Araçlar:** Ollama'nın yerel biçiminde bildirilir; akış halindeki araç çağrıları `arguments` + alanı nesne olan bütün çağrı kayıtlarıdır ve araç sonucu yeniden oynatma, çağrı kimliği ve araç + adına göre sıkı şekilde eşleştirilir. `tool_choice: "none"` ve `auto` normal çalışır; + **`required` veya tam adlandırılmış seçim fail closed** olur, çünkü Ollama'nın `/api/chat` + arabiriminde bunu dayatacak bir `tool_choice` alanı yoktur. +- **Yapılandırılmış çıktı kanonik Ollama Cloud'da reddedilir.** Ollama şu anda yapılandırılmış + çıktıyı Cloud'da desteklemediğini belgeliyor ve Cloud `format` alanını zorunlu kılmıyor; bu + yüzden OpenCodex, şema tanımlı bir isteğe karşılık serbest metin döndürmek yerine isteği kapatarak + başarısız kılar. Yerel ve özel `ollama-native` uç noktaları Ollama'nın yerel `format` eşlemesini + korur (`json_object` → `"json"`, `json_schema` → şema nesnesinin kendisi). + ## `openai-responses` **Hedefler:** OpenAI **Responses API**. **`passthrough: true`** — ham istek diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 4db3211bc5..e4f5497f8c 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -74,7 +74,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | Alan | Tip | Anlamı | | --- | --- | --- | -| `adapter` | `string` | `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` (veya takma ad `azure`) seçeneklerinden biri. | +| `adapter` | `string` | `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `ollama-native`, `azure-openai` (veya takma ad `azure`) seçeneklerinden biri. | | `baseUrl` | `string` | Yukarı akış API temel URL'si. Çoğu yerleşik sabit uç nokta uyumsuzluğu yok sayar; çakışma güvenli anahtar önayarları aynı adlı daha eski özel bir hedefi korur. | | `responsesPath?` | `string` | Anahtar kimlik doğrulamalı `openai-responses` istekleri için göreli kaynak yolu. `/` ile başlamalı ve şema, sorgu veya parça içermemelidir. | | `supportsServiceTier?` | `boolean` | Üç durumlu `service_tier` yeteneği. `true`: hızlı mod enjekte edebilir ve arayan değerleri korunur. `false`: alan kaldırılır ve asla enjekte edilmez (desteklemediği belgelenen yukarı akış bunu almamalıdır). Yok: sağlayıcı sınıflandırılmamıştır — arayan tarafından sağlanan değerler dokunulmadan korunur ve hızlı mod asla enjekte etmez. Kayıt defteri kurallı OpenAI'yi (`true`), DeepSeek'i ve Volcengine Ark'ı (`false`) sınıflandırır; bunu yalnızca katmanları gerçekten destekleyen özel ağ geçitleri için açıkça ayarlayın. | @@ -462,7 +462,6 @@ bildirir; senkronize edilen katalog `xhigh`'ı ayrı tutarken `max` bildirir. "defaultModel": "claude-sonnet-4-6" }, "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "apiKey": "${OLLAMA_API_KEY}", "defaultModel": "glm-5.2", diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index a73676e496..a4bbab6186 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -386,7 +386,11 @@ Cursor OAuth 和 live model discovery 已在这个实验性 adapter 中启用; ### Ollama Cloud -Ollama Cloud 是托管(而非本地)的 Ollama,在 `https://ollama.com/v1` 上兼容 OpenAI,密钥来自 [ollama.com/settings/keys](https://ollama.com/settings/keys)。opencodex 按视觉能力对其云端阵容进行分类,使 [vision sidecar](/zh-cn/guides/sidecars/) 仅对纯文本模型生效。纯文本模型(例如 `glm-5.2`、`deepseek-v4-pro`、`gpt-oss`、`qwen3-coder`、`minimax-m2.x`、`nemotron-3-*`)列在 `noVisionModels` 中;原生支持视觉的模型(例如 `kimi-k2.6`、`minimax-m3`、`gemma4`、`qwen3.5`、`gemini-3-flash-preview`)则不在其中。匹配能容忍 Ollama 的 `:size` 标签,因此 `gpt-oss` 涵盖 `gpt-oss:120b` 和 `gpt-oss:20b`。 +Ollama Cloud 是托管(而非本地)的 Ollama,配置地址为 `https://ollama.com/v1`,密钥来自 [ollama.com/settings/keys](https://ollama.com/settings/keys)。opencodex 通过 Ollama 自身的 REST API(`POST /api/chat`)连接,而不是 OpenAI 兼容接口,并从提供方动态发现模型列表,因此新的 Ollama Cloud 模型无需改动配置即可出现。opencodex 按视觉能力对其云端阵容进行分类,使 [vision sidecar](/zh-cn/guides/sidecars/) 仅对纯文本模型生效。纯文本模型(例如 `glm-5.2`、`deepseek-v4-pro`、`gpt-oss`、`qwen3-coder`、`minimax-m2.x`、`nemotron-3-*`)列在 `noVisionModels` 中;原生支持视觉的模型(例如 `kimi-k2.6`、`minimax-m3`、`gemma4`、`qwen3.5`、`gemini-3-flash-preview`)则不在其中。匹配能容忍 Ollama 的 `:size` 标签,因此 `gpt-oss` 涵盖 `gpt-oss:120b` 和 `gpt-oss:20b`。 + +Ollama 目前在文档中说明结构化输出在 Ollama Cloud 上不受支持。因此对正典 `ollama-cloud`, +opencodex 会以明确的错误拒绝结构化输出请求(`text.format`),而不是悄悄返回不受约束的自由 +文本;本地 / 自定义 `ollama-native` 端点保留 Ollama 原生的 `format` 行为。 ## 4. 本地提供商 diff --git a/docs-site/src/content/docs/zh-cn/guides/sidecars.md b/docs-site/src/content/docs/zh-cn/guides/sidecars.md index 522dfdb41c..ea758b48e8 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sidecars.md +++ b/docs-site/src/content/docs/zh-cn/guides/sidecars.md @@ -109,7 +109,6 @@ Dashboard 和管理 API 都使用 `gpt-5.4-mini` 作为回退。启动时仍会 { "providers": { "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] } diff --git a/docs-site/src/content/docs/zh-cn/reference/adapters.md b/docs-site/src/content/docs/zh-cn/reference/adapters.md index 3b124df7f6..046488f9e3 100644 --- a/docs-site/src/content/docs/zh-cn/reference/adapters.md +++ b/docs-site/src/content/docs/zh-cn/reference/adapters.md @@ -25,7 +25,7 @@ interface ProviderAdapter { ## `openai-chat` **目标:** OpenAI **Chat Completions**(`POST {baseUrl}/chat/completions`)以及所有兼容 provider, -包括 xAI、Kimi、DeepSeek、GLM、Groq、OpenRouter、Ollama(本地与云端)等。 +包括 xAI、Kimi、DeepSeek、GLM、Groq、OpenRouter、Ollama(本地)等。 **认证:** `key`(Bearer)。 - 把内部消息转换成 OpenAI role;工具映射为 `{type:"function", function:{…}}` 和 @@ -43,6 +43,38 @@ interface ProviderAdapter { `medium`、`high`、`xhigh` 或 `max` 档位,把 `delta.reasoning_content` 或 `delta.reasoning` 作为 reasoning delta,通过 `stream_options.include_usage` 请求流式 usage,并从非流式响应 envelope 中读取 usage。 +## `ollama-native` + +**目标:** Ollama 自有的 **Chat API**(`POST /api/chat`),而不是其 OpenAI 兼容接口。内置的 +`ollama-cloud` 提供方由 registry 选择到该 adapter;也可以在单独命名的自定义 / 自托管 Ollama +提供方上配置 `adapter: "ollama-native"`。 +**认证:** cloud / 自定义端点使用 `key`(Bearer);loopback 或 `authMode: "local"` +端点不会收到任何凭据。 + +- **registry 选择起决定作用。** 内置的 `ollama-cloud` 行保留 `https://ollama.com/v1` 作为 + `/v1/models` 动态发现的基础 URL,同时推理会规范化到 `POST https://ollama.com/api/chat`。 + 对该提供方行,配置中的 `adapter` 会被丢弃。普通内置本地 Ollama 仍走 `openai-chat`;为本 + 地或自托管端点选择 `ollama-native` 是显式的提供方配置决策,并按主机名判别,因此非 Ollama + 目标永远不会被悄悄改写。 +- **模型元数据:** `/v1/models` 不携带任何模型级元数据,因此在正典 Ollama Cloud 上,提供方 + 会通过 *有界限的* `POST /api/show`(每响应 256 KiB、每请求 8 秒、并发 4、48 个请求、整阶段 + 12 秒期限)补全每个被发现 id 的真实 context window 与 vision 能力。show 请求同源且从不 + 跟随重定向;失败只降级该模型,不会令发现本身失败。 +- **流式:** Ollama 原生 NDJSON。文本与 `message.thinking` delta 到达即转发;回合仅在 + `done: true` 终止记录上完成,缓冲的 `done: false` 或缺失终记录会完全抑制部分文本与工具调用。 +- **Reasoning:** 映射到 Ollama 原生 `think` 字段(`low`/`medium`/`high`/`max`,外加布尔值), + 按模型声明的档位收紧,并遵守上游配置的 `__omit__` sentinel 语义。 +- **图像:** 在模型具备 vision 能力时原样放入消息的 `images` 数组发送;video 会被拒绝而非 + 误发,远程图像 URL 不会被拉取。 +- **工具:** 以 Ollama 原生形状声明;流式 tool call 是 `arguments` 为对象的整调用记录, + tool result 回放按 call id 与工具名严格配对。`tool_choice: "none"` 与 `auto` 表现正常; + **`required` 或精确命名选择会 fail closed**,因为 Ollama 的 `/api/chat` 没有可用来强制它的 + `tool_choice` 字段。 +- **正典 Ollama Cloud 上拒绝结构化输出。** Ollama 目前在文档中说明其 Cloud 不支持结构化输出, + 且 Cloud 不会强制 `format` 字段,因此对按 schema 提出的请求,OpenCodex 会让其显式失败,而 + 不是返回不受约束的自由文本。本地 / 自定义 `ollama-native` 端点保留 Ollama 原生的 `format` + 映射(`json_object` → `"json"`,`json_schema` → schema 对象本身)。 + ## `openai-responses` **目标:** OpenAI **Responses API**。**`passthrough: true`** —— 通常原样转发请求与响应,仅对 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 3630a9ba6c..ef827e9d32 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -54,7 +54,7 @@ selector,而不是分配一个新名称。 | 字段 | 类型 | 含义 | | --- | --- | --- | -| `adapter` | `string` | `openai-chat`、`openai-responses`、`anthropic`、`google`、`kiro`、`cursor`、`azure-openai`(或别名 `azure`)之一。 | +| `adapter` | `string` | `openai-chat`、`openai-responses`、`anthropic`、`google`、`kiro`、`cursor`、`ollama-native`、`azure-openai`(或别名 `azure`)之一。 | | `baseUrl` | `string` | 上游 API 基础 URL。大多数内置固定端点会忽略不匹配的值;具备冲突安全键的预设会保留一个更早、同名的自定义目标。 | | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | 可选的客户端出站请求启动节流,与上游用量、计费和限流指标相互独立。提供商限制适用于所有模型,`models` 按上游模型精确 ID 匹配且只能增加延迟。排队等待不计入响应头超时。覆盖 HTTP、Responses WebSocket 以及显式适配器 `fetchResponse`/`runTurn` 调用。 | | `responsesPath?` | `string` | 用于 key-auth `openai-responses` 请求的相对资源路径。必须以 `/` 开头,且不能包含 scheme、query 或 fragment。 | @@ -340,7 +340,6 @@ OpenRouter 可以通过多个推理提供者来提供同一个模型。`openRout "defaultModel": "claude-sonnet-4-6" }, "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "apiKey": "${OLLAMA_API_KEY}", "defaultModel": "glm-5.2", diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index 28298c768a..0c20867124 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -484,14 +484,20 @@ Cursor 仍不會出現在 key-login list。 ### Ollama Cloud -Ollama Cloud 是 hosted、不是 local 的 Ollama,在 `https://ollama.com/v1` 提供 OpenAI-compatible API, -key 來自 [ollama.com/settings/keys](https://ollama.com/settings/keys)。opencodex 依 vision capability 分類其 +Ollama Cloud 是 hosted、不是 local 的 Ollama,設定位址為 `https://ollama.com/v1`, +key 來自 [ollama.com/settings/keys](https://ollama.com/settings/keys)。opencodex 以 Ollama 自身的 +REST API(`POST /api/chat`)連線,而非 OpenAI-compatible 介面,並向 provider 動態探索模型清單, +因此新的 Ollama Cloud 模型不需改設定就會出現。opencodex 依 vision capability 分類其 cloud lineup,讓 [vision sidecar](/zh-tw/guides/sidecars/) 只對純文字模型生效。純文字模型,例如 `glm-5.2`、`deepseek-v4-pro`、`gpt-oss`、`qwen3-coder`、`minimax-m2.x`、`nemotron-3-*`,會列在 `noVisionModels`;原生 vision 模型,例如 `kimi-k2.6`、`minimax-m3`、`gemma4`、`qwen3.5`、 `gemini-3-flash-preview`,不會列入。matching 可容忍 Ollama 的 `:size` tag,因此 `gpt-oss` 同時涵蓋 `gpt-oss:120b` 與 `gpt-oss:20b`。 +Ollama 目前在文件中說明結構化輸出在 Ollama Cloud 上不受支援。因此對正典 `ollama-cloud`, +opencodex 會以明確的錯誤拒絕結構化輸出請求(`text.format`),而不是悄悄回傳不受約束的 +散文式文字;本機 / 自訂 `ollama-native` 端點保留 Ollama 原生的 `format` 行為。 + ## 4. 本機供應商 讓 opencodex 指向本機 OpenAI-compatible server,通常使用空 key: diff --git a/docs-site/src/content/docs/zh-tw/guides/sidecars.md b/docs-site/src/content/docs/zh-tw/guides/sidecars.md index 1ffe807961..5d9c7d1de4 100644 --- a/docs-site/src/content/docs/zh-tw/guides/sidecars.md +++ b/docs-site/src/content/docs/zh-tw/guides/sidecars.md @@ -104,7 +104,6 @@ OAuth 帳號時使用 `anthropic`,否則使用 `openai`。明確選擇 `anthro { "providers": { "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] } diff --git a/docs-site/src/content/docs/zh-tw/reference/adapters.md b/docs-site/src/content/docs/zh-tw/reference/adapters.md index f1312d818c..c92be2b73d 100644 --- a/docs-site/src/content/docs/zh-tw/reference/adapters.md +++ b/docs-site/src/content/docs/zh-tw/reference/adapters.md @@ -25,7 +25,7 @@ interface ProviderAdapter { ## `openai-chat` **目標:** OpenAI **Chat Completions**(`POST {baseUrl}/chat/completions`)以及所有相容 provider, -包括 xAI、Kimi、DeepSeek、GLM、Groq、OpenRouter、Ollama(本機與雲端)等。 +包括 xAI、Kimi、DeepSeek、GLM、Groq、OpenRouter、Ollama(本機)等。 **認證:** `key`(Bearer)。 - 把內部訊息轉換成 OpenAI role;工具對映為 `{type:"function", function:{…}}` 和 @@ -42,6 +42,38 @@ interface ProviderAdapter { 的 reasoning delta,以 `stream_options.include_usage` 請求串流 usage,並從非串流回應 envelope 讀取 usage。 +## `ollama-native` + +**目標:** Ollama 自身的 **Chat API**(`POST /api/chat`),而非其 OpenAI 相容介面。內建的 +`ollama-cloud` 提供者由 registry 選擇到此 adapter;也可以在另外命名的自訂 / 自架 Ollama +提供者上設定 `adapter: "ollama-native"`。 +**驗證:** cloud / 自訂端點使用 `key`(Bearer);loopback 或 `authMode: "local"` 端點不會 +收到任何憑證。 + +- **registry 選擇具有決定性。** 內建 `ollama-cloud` 列保留 `https://ollama.com/v1` 作為 + `/v1/models` 動態探索的基礎 URL,同時推論會正規化到 `POST https://ollama.com/api/chat`。 + 對該提供者列,設定中的 `adapter` 會被丟棄。一般內建本機 Ollama 仍在 `openai-chat`;為本機 + 或自架端點選擇 `ollama-native` 是明確的提供者設定決定,並依主機判別,因此非 Ollama 目的 + 地不會被默默改寫。 +- **模型中繼資料:** `/v1/models` 不攜帶任何模型級中繼資料,因此在正典 Ollama Cloud 上, + 提供者會透過 *有界限的* `POST /api/show`(每回應 256 KiB、每請求 8 秒、並行 4、48 個請求、 + 整階段 12 秒期限)補上每個被探索 id 的真實 context window 與 vision 能力。show 請求同源 + 且絕不跟隨重新導向;失敗只會降級該模型,不會讓探索本身失敗。 +- **串流:** Ollama 原生 NDJSON。文字與 `message.thinking` delta 隨到隨轉發;回合僅在 + `done: true` 終止記錄上完成,緩衝的 `done: false` 或缺少終端會完全抑制部分文字與工具呼叫。 +- **Reasoning:** 對映到 Ollama 原生 `think` 欄位(`low`/`medium`/`high`/`max`,外加布林值), + 依模型宣告的層級夾限,並遵守上游設定的 `__omit__` sentinel 語義。 +- **圖像:** 在模型具備 vision 能力時,原樣放進訊息的 `images` 陣列送出;video 會被拒絕而非 + 誤送,遠端圖像 URL 不會被擷取。 +- **工具:** 以 Ollama 原生形狀宣告;串流 tool call 是 `arguments` 為物件的整呼叫記錄, + tool result 重播按 call id 與工具名嚴格配對。`tool_choice: "none"` 與 `auto` 正常運作; + **`required` 或精確名稱選擇會 fail closed**,因為 Ollama 的 `/api/chat` 沒有可用來強制它的 + `tool_choice` 欄位。 +- **正典 Ollama Cloud 上拒絕結構化輸出。** Ollama 目前在文件中說明其 Cloud 不支援結構化輸出, + 且 Cloud 不會強制 `format` 欄位,因此 OpenCodex 會讓該請求顯式失敗,而不是在 schema 指定的 + 請求上回傳不受約束的散文。本機 / 自訂 `ollama-native` 端點保留 Ollama 原生的 `format` 映射 + (`json_object` → `"json"`,`json_schema` → schema 物件本身)。 + ## `openai-responses` **目標:** OpenAI **Responses API**。**`passthrough: true`** —— 轉發原始請求 body,並把回應 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index b0a46f49ec..398a396dc7 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -38,7 +38,7 @@ description: 供應商項目、認證、端點、模型目錄、配額、context | 欄位 | 型別 | 意義 | | --- | --- | --- | -| `adapter` | `string` | `openai-chat`、`openai-responses`、`anthropic`、`google`、`kiro`、`cursor`、`azure-openai`(或別名 `azure`)之一。 | +| `adapter` | `string` | `openai-chat`、`openai-responses`、`anthropic`、`google`、`kiro`、`cursor`、`ollama-native`、`azure-openai`(或別名 `azure`)之一。 | | `baseUrl` | `string` | 上游 API base URL。多數內建固定端點忽略不符;碰撞安全的金鑰預設保留較舊的同名自訂目的地。 | | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | 選用的用戶端出站請求啟動節流,與上游用量、計費及限流指標彼此獨立。供應商限制適用於所有模型,`models` 依上游模型精確 ID 比對且只能增加延遲。排隊等待不計入回應標頭逾時。涵蓋 HTTP、Responses WebSocket 及明確的適配器 `fetchResponse`/`runTurn` 呼叫。 | | `responsesPath?` | `string` | Key-auth `openai-responses` 請求的相對資源路徑。必須以 `/` 開頭且不含 scheme、query 或 fragment。 | @@ -308,7 +308,6 @@ OpenRouter 可透過多個推論供應商提供一個模型。`openRouterRouting "defaultModel": "claude-sonnet-4-6" }, "ollama-cloud": { - "adapter": "openai-chat", "baseUrl": "https://ollama.com/v1", "apiKey": "${OLLAMA_API_KEY}", "defaultModel": "glm-5.2", diff --git a/src/adapters/ollama-native-url.ts b/src/adapters/ollama-native-url.ts new file mode 100644 index 0000000000..ef84210c1d --- /dev/null +++ b/src/adapters/ollama-native-url.ts @@ -0,0 +1,111 @@ +/** + * URL policy for Ollama's native REST API. + * + * The built-in Ollama provider historically stored an OpenAI-compatible `/v1` base URL. The + * native adapter deliberately canonicalizes that compatibility spelling only for Ollama's known + * local/cloud hosts. An arbitrary custom host with a `/v1` path is never silently rewritten. + */ + +export type OllamaNativeEndpointKind = "local" | "cloud" | "custom"; + +const LOCAL_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]); +const CLOUD_HOSTNAMES = new Set(["ollama.com"]); +const REJECTED_CLOUD_ALIAS_HOSTNAMES = new Set(["www.ollama.com"]); + +function normalizedHostname(url: URL): string { + const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/gu, ""); + return hostname.endsWith(".") ? hostname.slice(0, -1) : hostname; +} + +function normalizedPath(url: URL): string { + const path = url.pathname.replace(/\/+$/, ""); + return path === "/" ? "" : path; +} + +function endpointKind(url: URL): OllamaNativeEndpointKind { + // WHATWG URL keeps IPv6 brackets in `hostname` on Bun/Node (`[::1]`), while the + // loopback policy is stored in its canonical host form (`::1`). + const hostname = normalizedHostname(url); + if (REJECTED_CLOUD_ALIAS_HOSTNAMES.has(hostname)) { + throw new Error("ollama-native requires canonical Ollama Cloud host ollama.com; www.ollama.com is rejected"); + } + if (LOCAL_HOSTNAMES.has(hostname)) return "local"; + if (CLOUD_HOSTNAMES.has(hostname)) return "cloud"; + return "custom"; +} + +function assertCloudTransport(url: URL, kind: OllamaNativeEndpointKind): void { + if (kind !== "cloud") return; + if (url.protocol !== "https:") { + throw new Error("ollama-native canonical Ollama Cloud requires HTTPS"); + } + if (url.port) { + throw new Error("ollama-native canonical Ollama Cloud rejects non-default ports"); + } +} + +function parseBaseUrl(baseUrl: string): URL { + const trimmed = baseUrl.trim(); + if (!trimmed) throw new Error("ollama-native requires a non-empty baseUrl"); + + let url: URL; + try { + url = new URL(trimmed); + } catch { + throw new Error("ollama-native requires an absolute http(s) baseUrl"); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("ollama-native only supports http(s) base URLs"); + } + if (url.username || url.password || url.search || url.hash) { + throw new Error("ollama-native baseUrl must not contain credentials, a query, or a fragment"); + } + return url; +} + +/** Return the endpoint family used by native authentication policy. */ +export function ollamaNativeEndpointKind(baseUrl: string): OllamaNativeEndpointKind { + const url = parseBaseUrl(baseUrl); + const kind = endpointKind(url); + assertCloudTransport(url, kind); + return kind; +} + +/** True when the base URL points at canonical Ollama Cloud (not a self-hosted destination). */ +export function isCanonicalOllamaCloudUrl(baseUrl: string): boolean { + return ollamaNativeEndpointKind(baseUrl) === "cloud"; +} + +/** + * Build the native chat endpoint from a configured base URL. + * + * Recognized compatibility forms on canonical Ollama hosts: + * - `/`, `/api`, `/api/chat` + * - legacy `/v1` and `/v1/chat/completions` + * + * For an unrelated custom host only `/`, `/api`, and `/api/chat` are accepted. In particular, + * `/v1` is rejected instead of being stripped or guessed at. + */ +export function ollamaNativeChatUrl(baseUrl: string): string { + const url = parseBaseUrl(baseUrl); + const kind = endpointKind(url); + assertCloudTransport(url, kind); + const path = normalizedPath(url); + const canonicalPaths = new Set(["", "/api", "/api/chat", "/v1", "/v1/chat/completions"]); + const customPaths = new Set(["", "/api", "/api/chat"]); + + if (kind === "custom" && !customPaths.has(path)) { + throw new Error( + `ollama-native refuses custom baseUrl path "${path || "/"}"; use a native root, /api, or /api/chat`, + ); + } + if (kind !== "custom" && !canonicalPaths.has(path)) { + throw new Error( + `ollama-native refuses Ollama baseUrl path "${path || "/"}"; use root, /v1, /api, or /api/chat`, + ); + } + + if (kind === "cloud") url.hostname = normalizedHostname(url); + url.pathname = "/api/chat"; + return url.toString(); +} diff --git a/src/adapters/ollama-native.ts b/src/adapters/ollama-native.ts new file mode 100644 index 0000000000..569ae90622 --- /dev/null +++ b/src/adapters/ollama-native.ts @@ -0,0 +1,1131 @@ +import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "./base"; +import { randomUUID } from "node:crypto"; +import type { + AdapterEvent, + OcxAssistantMessage, + OcxContentPart, + OcxMessage, + OcxParsedRequest, + OcxProviderConfig, + OcxThinkingContent, + OcxToolCall, + OcxUsage, +} from "../types"; +import { + isAllowedToolChoice, + modelInList, + namespacedToolName, + toolChoiceToolPredicate, +} from "../types"; +import { configuredReasoningEfforts, isReasoningEffortOmitted, mapReasoningEffort, modelRecordValue, reasoningEffortMapFor } from "../reasoning-effort"; +import { + readBoundedResponseBytes, +} from "../lib/bounded-body"; +import { debugProviderDiagnostic } from "../lib/debug"; +import { + isTranslatorBudgetExceededError, + retainTranslatedEventBatch, + TRANSLATOR_MAX_SSE_EVENT_BYTES, + TranslatorBudgetExceededError, + type TranslatorBudget, +} from "../lib/translator-budget"; +import { redactSecretString, SENSITIVE_KEY_PATTERN } from "../lib/redact"; +import { parseDataUrl } from "./image"; +import { + ollamaNativeChatUrl, + ollamaNativeEndpointKind, + type OllamaNativeEndpointKind, +} from "./ollama-native-url"; + +/** Native `/api/chat` message shape used by this adapter. */ +export interface OllamaNativeMessage { + role: "system" | "user" | "assistant" | "tool"; + content: string; + thinking?: string; + images?: string[]; + tool_call_id?: string; + tool_name?: string; + tool_calls?: Array<{ + type: "function"; + function: { + index?: number; + name: string; + arguments: Record; + }; + id?: string; + }>; +} + +interface OllamaNativeTool { + type: "function"; + function: { + name: string; + description?: string; + parameters: Record; + }; +} + +interface PendingToolCall { + id: string; + name: string; + namespace?: string; + wireName: string; + order: number; + result?: OcxMessage & { role: "toolResult" }; +} + +interface PendingToolBatch { + calls: PendingToolCall[]; + byId: Map; +} + +interface NativeStreamToolCall { + key: string; + budgetKey: string; + order: number; + name: string; + nativeId?: string; + nativeIndex?: number; + arguments: Record; + argumentBytes: number; +} + +interface NativeStreamState { + toolCalls: Map; + nextToolOrder: number; + usage?: OcxUsage; + stopReason?: string; + sawMessage: boolean; + terminal: boolean; + terminalError: boolean; + allowParallelToolCalls: boolean; +} + +type JsonRecord = Record; +type NativeReadResult = { done: false; value: Uint8Array } | { done: true; value?: undefined }; + +const NATIVE_THINK_VALUES = new Set(["low", "medium", "high", "max"]); +const NATIVE_TOOL_ID_MAX_LENGTH = 256; +const NATIVE_TOOL_ID_CONTROL = /[\u0000-\u001f\u007f]/u; + +function isRecord(value: unknown): value is JsonRecord { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isFiniteNonNegativeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +/** + * Provider-owned call ids are carried into the client-visible Responses call_id field, so never + * expose malformed or unbounded strings. A duplicate native id is treated like an unusable id: + * Ollama is allowed to omit ids or repeat them across requests, while the OCX history contract + * requires one stable, globally unique pairing key. + */ +function validNativeToolCallId(value: unknown): string | undefined { + if ( + typeof value !== "string" + || value.length === 0 + || value.length > NATIVE_TOOL_ID_MAX_LENGTH + || value !== value.trim() + || NATIVE_TOOL_ID_CONTROL.test(value) + ) return undefined; + return value; +} + +function mintNativeToolCallId(nativeIndex: number | undefined, issuedIds: Set): string { + let id = ""; + do { + id = "ollama_call_" + randomUUID() + "_" + (nativeIndex ?? "na"); + } while (issuedIds.has(id)); + issuedIds.add(id); + return id; +} + +function allocateNativeToolCallId( + nativeId: unknown, + nativeIndex: number | undefined, + issuedIds: Set, +): string { + const valid = validNativeToolCallId(nativeId); + if (valid && !issuedIds.has(valid)) { + issuedIds.add(valid); + return valid; + } + return mintNativeToolCallId(nativeIndex, issuedIds); +} + +function safeNativeString(value: unknown, fallback: string): string { + if (typeof value !== "string") return fallback; + const redacted = redactSecretString(value.trim()); + return redacted.length > 400 ? `${redacted.slice(0, 400)}…` : redacted; +} + +function errorDetail(value: unknown): string | undefined { + if (typeof value === "string") return value.trim() || undefined; + if (!isRecord(value)) return undefined; + if (typeof value.error === "string" && value.error.trim()) return value.error.trim(); + if (isRecord(value.error) && typeof value.error.message === "string" && value.error.message.trim()) { + return value.error.message.trim(); + } + if (typeof value.detail === "string" && value.detail.trim()) return value.detail.trim(); + if (typeof value.message === "string" && value.message.trim()) return value.message.trim(); + return undefined; +} + +function nativeErrorEvent( + detail: unknown, + usage?: OcxUsage, + status = 502, +): Extract { + return { + type: "error", + status, + errorType: "upstream_error", + code: "ollama_native_error", + message: safeNativeString(errorDetail(detail), "Ollama native upstream error"), + ...(usage ? { usage } : {}), + }; +} + +function malformedNativeEvent(message: string, usage?: OcxUsage): Extract { + return { + type: "error", + status: 502, + errorType: "upstream_error", + code: "invalid_ollama_native_payload", + message, + ...(usage ? { usage } : {}), + }; +} + +function translationBudgetEvent(usage?: OcxUsage): Extract { + return { + type: "error", + status: 502, + errorType: "upstream_error", + code: "translation_buffer_limit", + message: "upstream translation buffer exceeded the safe limit", + ...(usage ? { usage } : {}), + }; +} + +function wireModelId(provider: OcxProviderConfig, modelId: string): string { + if (!provider.modelSuffixBracketStrip) return modelId; + const end = modelId.trimEnd(); + if (!end.endsWith("]")) return modelId; + const start = end.lastIndexOf("["); + return start > 0 ? end.slice(0, start) : modelId; +} + +function assertObjectArguments(value: unknown, label: string): Record { + if (!isRecord(value)) throw new Error(`ollama-native ${label} arguments must be a JSON object`); + return value; +} + +function normalizedBase64(value: string, label: string): string { + const base64 = value.replace(/\s+/g, ""); + if ( + base64.length === 0 + || !/^[A-Za-z0-9+/]*={0,2}$/.test(base64) + || base64.length % 4 === 1 + ) { + throw new Error(`ollama-native ${label} image is not valid base64`); + } + return base64; +} + +function imageToBase64(imageUrl: string, label: string): string { + const data = parseDataUrl(imageUrl); + if (data) { + if (!data.mediaType.toLowerCase().startsWith("image/")) { + throw new Error(`ollama-native ${label} image data URL is not an image`); + } + return normalizedBase64(data.base64, label); + } + if (/^https?:\/\//i.test(imageUrl)) { + throw new Error(`ollama-native does not fetch remote ${label} image URLs; provide a data URL/base64 image`); + } + return normalizedBase64(imageUrl, label); +} + +function contentToNative( + content: string | OcxContentPart[], + label: string, + allowImages = true, +): { content: string; images?: string[] } { + if (typeof content === "string") return { content }; + let text = ""; + const images: string[] = []; + for (const part of content) { + if (part.type === "text") { + text += part.text; + continue; + } + // Ollama's native /api/chat message shape carries `images: string[]` and has no video + // counterpart, so a video part is refused rather than silently dropped or mis-sent as an image. + if (part.type === "video") throw new Error(`ollama-native cannot send video content in ${label}`); + if (!allowImages) throw new Error(`ollama-native cannot preserve images in ${label} developer content`); + images.push(imageToBase64(part.imageUrl, label)); + } + return images.length > 0 ? { content: text, images } : { content: text }; +} + +function assistantTextThinkingAndCalls(message: OcxAssistantMessage): { + content: string; + thinking?: string; + calls: OcxToolCall[]; +} { + let content = ""; + let thinking = ""; + const calls: OcxToolCall[] = []; + for (const part of message.content) { + if (part.type === "text") content += part.text; + else if (part.type === "thinking") thinking += (part as OcxThinkingContent).thinking; + else if (part.type === "toolCall") calls.push(part); + } + return { + content, + ...(thinking ? { thinking } : {}), + calls, + }; +} + +function buildNativeMessages( + parsed: OcxParsedRequest, + reservedToolCallIds: Set, +): OllamaNativeMessage[] { + const messages: OllamaNativeMessage[] = []; + for (const system of parsed.context.systemPrompt ?? []) { + messages.push({ role: "system", content: system }); + } + + // A request-boundary adapter is a fresh object in production. Reserve every id already + // present in the parsed history before the next provider response is translated, so a provider + // id reused on a later request cannot become a duplicate OCX call_id. The set is deliberately + // owned by this adapter/request lifecycle rather than process-global state. + reservedToolCallIds.clear(); + let pending: PendingToolBatch | undefined; + + const flushPending = (): void => { + if (!pending) return; + for (const call of pending.calls) { + if (!call.result) { + throw new Error(`ollama-native tool call ${call.id} is missing its tool result; refusing interrupted replay`); + } + } + for (const call of pending.calls) { + const result = call.result!; + const translated = contentToNative(result.content, "tool result"); + messages.push({ + role: "tool", + tool_call_id: call.id, + tool_name: call.wireName, + content: translated.content, + ...(translated.images ? { images: translated.images } : {}), + }); + } + pending = undefined; + }; + + for (const message of parsed.context.messages) { + if (message.role === "toolResult") { + if (!pending) { + throw new Error(`ollama-native orphan tool result ${message.toolCallId || ""}`); + } + const call = pending.byId.get(message.toolCallId); + if (!call) { + throw new Error(`ollama-native tool result ${message.toolCallId || ""} has no originating call`); + } + if (call.result) { + throw new Error(`ollama-native duplicate tool result for ${message.toolCallId}`); + } + if (call.name !== message.toolName || call.namespace !== message.toolNamespace) { + throw new Error(`ollama-native tool result ${message.toolCallId} names the wrong originating tool`); + } + call.result = message; + continue; + } + + // Native Ollama requires the whole assistant tool-call turn followed by its tool results. A + // new conversational message is a hard boundary; unresolved calls are never fabricated. + if (pending) flushPending(); + + switch (message.role) { + case "user": { + const translated = contentToNative(message.content, "user"); + messages.push({ role: "user", content: translated.content, ...(translated.images ? { images: translated.images } : {}) }); + break; + } + case "developer": { + const translated = contentToNative(message.content, "developer", false); + messages.push({ role: "system", content: translated.content }); + break; + } + case "assistant": { + const extracted = assistantTextThinkingAndCalls(message); + const wireCalls: PendingToolCall[] = []; + const nativeCalls = extracted.calls.map((call, index) => { + if (!call.id || reservedToolCallIds.has(call.id)) { + throw new Error(`ollama-native assistant tool call id is missing or duplicated: ${call.id || ""}`); + } + reservedToolCallIds.add(call.id); + const args = assertObjectArguments(call.arguments, `assistant tool call ${call.id}`); + // `customWireName` belongs to the prior caller/provider wire. It must not override + // this adapter's deterministic namespace flattening during replay: a native turn is + // paired by the OCX name/namespace, then lowered to the native wire name here. + const wireName = namespacedToolName(call.namespace, call.name); + if (!wireName) throw new Error(`ollama-native assistant tool call ${call.id} has no name`); + const pendingCall: PendingToolCall = { + id: call.id, + name: call.name, + namespace: call.namespace, + wireName, + order: index, + }; + wireCalls.push(pendingCall); + return { + type: "function" as const, + id: call.id, + function: { index, name: wireName, arguments: args }, + }; + }); + const native: OllamaNativeMessage = { + role: "assistant", + content: extracted.content, + ...(extracted.thinking ? { thinking: extracted.thinking } : {}), + ...(nativeCalls.length > 0 ? { tool_calls: nativeCalls } : {}), + }; + messages.push(native); + if (wireCalls.length > 0) { + pending = { calls: wireCalls, byId: new Map(wireCalls.map(call => [call.id, call])) }; + } + break; + } + } + } + if (pending) flushPending(); + return messages; +} + +function buildNativeTools(parsed: OcxParsedRequest): OllamaNativeTool[] | undefined { + const declared = parsed.context.tools; + if (!declared || declared.length === 0 || parsed.options.toolChoice === "none") return undefined; + + const choice = parsed.options.toolChoice; + if ( + choice === "required" + || (isAllowedToolChoice(choice) && choice.mode === "required") + || (choice && typeof choice === "object" && !isAllowedToolChoice(choice) && "name" in choice) + ) { + throw new Error("ollama-native does not support required or exact named tool_choice"); + } + const predicate = toolChoiceToolPredicate(choice, declared); + const seenNames = new Set(); + const tools: OllamaNativeTool[] = []; + for (const tool of declared) { + if (!predicate(tool)) continue; + const name = namespacedToolName(tool.namespace, tool.name); + if (!name || seenNames.has(name)) throw new Error(`ollama-native duplicate flattened tool name: ${name || ""}`); + if (!isRecord(tool.parameters)) throw new Error(`ollama-native tool ${name} has no JSON schema object`); + seenNames.add(name); + tools.push({ + type: "function", + function: { + name, + ...(tool.description ? { description: tool.description } : {}), + // Native Ollama accepts the schema directly. In particular, do not copy OpenAI's + // function.strict flag: `/api/chat` has no documented strict field. + parameters: tool.parameters, + }, + }); + } + return tools.length > 0 ? tools : undefined; +} + +function nativeThink( + provider: OcxProviderConfig, + parsed: OcxParsedRequest, +): false | true | "low" | "medium" | "high" | "max" | undefined { + const requested = parsed.options.reasoning; + // The Responses parser leaves reasoning undefined when the caller made no reasoning decision. + // Ollama distinguishes an omitted think field from think:false; preserve that distinction. + if (requested === undefined) return undefined; + // An explicit `__omit__` wire mapping (issue #2356) is an intentional decision to send NO + // reasoning field. mapReasoningEffort() collapses the sentinel to `undefined`, and the + // `?? requested` fallback below would then re-emit the requested label — defeating the + // sentinel. Upstream consults only the BOUNDARY spelling (`ultra` → `max`) and states raw + // ultra must never influence the provider wire, so only wireMap[boundary] can authorize an + // omission. The explicit mapping is checked before the native `none`/noReasoning fallbacks so + // it stays authoritative over them. + const wireMap = reasoningEffortMapFor(provider, parsed.modelId); + if (wireMap) { + const boundary = requested === "ultra" ? "max" : requested; + if (isReasoningEffortOmitted(wireMap[boundary])) return undefined; + } + if (requested === "none" || modelInList(provider.noReasoningModels, parsed.modelId)) return false; + // Upstream intentionally advertises synthetic top rungs on routed rows so Codex/subagent effort + // overrides validate against catalog membership; the wire stays honest because the native + // adapter clamps the requested effort onto the provider's real supported ladder + // (clampToSupportedCodexEffort: max/ultra on a [low,medium,high] model serializes "high"). + const mapped = mapReasoningEffort(provider, parsed.modelId, requested); + if (mapped !== undefined) { + let value = mapped; + if (value === "minimal") value = "low"; + if (value === "xhigh" || value === "ultra") value = "max"; + if (value === "enabled" || value === "adaptive" || value === "true") return true; + if (value === "disabled" || value === "false") return false; + if (NATIVE_THINK_VALUES.has(value)) return value as "low" | "medium" | "high" | "max"; + throw new Error(`ollama-native does not support reasoning level "${redactSecretString(value)}"`); + } + // mapReasoningEffort() returned undefined. For an ordinary Codex label against a declared + // non-empty ladder this can ONLY be an authoritative post-clamp `__omit__` sentinel (the + // clamp resolved the requested effort onto a rung whose wire mapping is the sentinel) — + // honour it; never resurrect the raw requested label. Native boolean aliases have no + // mapping at all, so their raw passthrough stays isolated here. + const supported = configuredReasoningEfforts(provider, parsed.modelId); + const ordinaryLabel = requested === "minimal" || requested === "low" || requested === "medium" + || requested === "high" || requested === "xhigh" || requested === "ultra" + || requested === "max"; + if (supported !== undefined && supported.length > 0 && ordinaryLabel) return undefined; + let value = requested; + if (value === "minimal") value = "low"; + if (value === "enabled" || value === "adaptive" || value === "true") return true; + if (value === "disabled" || value === "false") return false; + if (NATIVE_THINK_VALUES.has(value)) return value as "low" | "medium" | "high" | "max"; + throw new Error(`ollama-native does not support reasoning level "${redactSecretString(value)}"`); +} + +function nativeFormat( + parsed: OcxParsedRequest, + endpointKind: OllamaNativeEndpointKind, +): "json" | Record | undefined { + const format = parsed.options.textFormat; + if (!format) return undefined; + // Ollama's own documentation states "Ollama's Cloud currently does not support structured + // outputs" (docs/capabilities/structured-outputs.mdx). Cloud does not reject `format`: it + // returns 200 and ignores the constraint, so sending it would turn an output-shape contract + // into unconstrained prose the caller believes is schema-valid. Refuse the contract instead, + // the same call Kiro makes for a wire that cannot enforce it. Local and custom self-hosted + // Ollama keep the native `format` mapping, which their contract does honour. + if (endpointKind === "cloud") { + throw new Error("ollama-native does not support structured output on Ollama Cloud"); + } + if (format.type === "json_object") return "json"; + if (!format.schema || !isRecord(format.schema)) { + throw new Error("ollama-native json_schema output requires a JSON schema object"); + } + // Ollama's native contract takes the schema itself, unlike OpenAI's response_format wrapper. + return format.schema; +} + +function usageFromNative(value: JsonRecord | undefined): OcxUsage | undefined { + if (!value) return undefined; + const input = isFiniteNonNegativeInteger(value.prompt_eval_count) ? value.prompt_eval_count : undefined; + const output = isFiniteNonNegativeInteger(value.eval_count) ? value.eval_count : undefined; + if (input === undefined && output === undefined) return undefined; + return { inputTokens: input ?? 0, outputTokens: output ?? 0 }; +} + +function stopReasonFromNative(value: unknown): string | undefined { + if (typeof value !== "string" || !value.trim()) return undefined; + if (value === "length") return "max_tokens"; + return value; +} + +function nativeMessageEvents(message: JsonRecord, state: NativeStreamState, budget: TranslatorBudget): AdapterEvent[] { + const events: AdapterEvent[] = []; + if (message.role !== undefined && message.role !== "assistant") { + throw new Error("ollama-native response message role was not assistant"); + } + // Deltas are forwarded as they arrive; the parser keeps no second complete copy of the + // response. In-flight memory is bounded by the per-line reservation in the stream reader and + // the bounded buffered read, matching how the openai-chat adapter accounts deltas. + if (message.thinking !== undefined) { + if (typeof message.thinking !== "string") throw new Error("ollama-native response thinking was not text"); + if (message.thinking) events.push({ type: "reasoning_raw_delta", text: message.thinking }); + } + if (message.content !== undefined) { + if (typeof message.content !== "string") throw new Error("ollama-native response content was not text"); + if (message.content) events.push({ type: "text_delta", text: message.content }); + } + if (message.tool_calls !== undefined) { + if (!Array.isArray(message.tool_calls)) throw new Error("ollama-native response tool_calls was not an array"); + for (let position = 0; position < message.tool_calls.length; position++) { + const rawCall = message.tool_calls[position]; + if (!isRecord(rawCall) || !isRecord(rawCall.function)) { + throw new Error("ollama-native response tool call was malformed"); + } + const fn = rawCall.function; + if (typeof fn.name !== "string" || !fn.name.trim()) throw new Error("ollama-native response tool call had no name"); + const args = assertObjectArguments(fn.arguments, "response tool call"); + const index = isFiniteNonNegativeInteger(fn.index) ? fn.index : undefined; + const nativeId = validNativeToolCallId(rawCall.id); + const key = index === undefined ? `position:${position}` : `index:${index}`; + // Tool-call identity is explicitly keyed by the provider index when supplied: a later + // frame for the same index updates that call's arguments, while a distinct index creates a + // second call. This narrow tool-call compatibility rule is independent from text/thinking + // semantics, where every non-empty native field is an appended partial delta. + const existing = state.toolCalls.get(key); + if (!existing && !state.allowParallelToolCalls && state.toolCalls.size > 0) { + throw new Error("ollama-native provider emitted parallel tool calls while parallelToolCalls:false was requested"); + } + if (existing) { + if (existing.name !== fn.name) throw new Error("ollama-native response reused a tool-call index for another function"); + if (nativeId && existing.nativeId && nativeId !== existing.nativeId) { + throw new Error("ollama-native response changed a tool-call id for an existing index"); + } + if (!existing.nativeId && nativeId) existing.nativeId = nativeId; + replaceNativeToolArguments(existing, args, budget); + } else { + const call: NativeStreamToolCall = { + key, + budgetKey: `ollama-native:${key}`, + order: state.nextToolOrder++, + name: fn.name, + ...(nativeId ? { nativeId } : {}), + ...(index !== undefined ? { nativeIndex: index } : {}), + arguments: args, + argumentBytes: 0, + }; + budget.openCall(call.budgetKey); + try { + replaceNativeToolArguments(call, args, budget); + state.toolCalls.set(key, call); + } catch (error) { + budget.closeCall(call.budgetKey); + throw error; + } + } + events.push({ type: "heartbeat" }); + } + } + state.sawMessage = true; + return events; +} + +function flushNativeStreamToolCalls( + state: NativeStreamState, + issuedToolCallIds: Set, +): AdapterEvent[] { + const events: AdapterEvent[] = []; + const ordered = [...state.toolCalls.values()].sort((a, b) => a.order - b.order); + for (const call of ordered) { + const id = allocateNativeToolCallId(call.nativeId, call.nativeIndex, issuedToolCallIds); + events.push({ type: "tool_call_start", id, name: call.name }); + events.push({ type: "tool_call_delta", arguments: JSON.stringify(call.arguments) }); + events.push({ type: "tool_call_end" }); + } + return events; +} + +function replaceNativeToolArguments( + call: NativeStreamToolCall, + args: Record, + budget: TranslatorBudget, +): void { + const nextBytes = new TextEncoder().encode(JSON.stringify(args)).byteLength; + if (nextBytes > TRANSLATOR_MAX_SSE_EVENT_BYTES) { + throw new TranslatorBudgetExceededError("tool_args", TRANSLATOR_MAX_SSE_EVENT_BYTES); + } + if (nextBytes === 0) { + if (call.argumentBytes > 0) budget.releaseRetained(call.argumentBytes, { kind: "tool_args", callId: call.budgetKey }); + call.arguments = args; + call.argumentBytes = 0; + return; + } + const reservation = budget.reserveTransient(nextBytes, { kind: "tool_args", callId: call.budgetKey }); + try { + reservation.commitRetained(); + if (call.argumentBytes > 0) budget.releaseRetained(call.argumentBytes, { kind: "tool_args", callId: call.budgetKey }); + call.arguments = args; + call.argumentBytes = nextBytes; + } catch (error) { + reservation.release(); + throw error; + } +} + +function releaseNativeStateBuffers(state: NativeStreamState, budget: TranslatorBudget): void { + for (const call of state.toolCalls.values()) budget.closeCall(call.budgetKey); +} + +function nativeBodyMessage(value: unknown): JsonRecord { + if (!isRecord(value)) throw new Error("ollama-native response message was missing or malformed"); + return value; +} + +function nativeEventsFromResponsePayload( + payload: unknown, + budget: TranslatorBudget, + issuedToolCallIds: Set, + allowParallelToolCalls = true, +): AdapterEvent[] { + if (!isRecord(payload)) return [malformedNativeEvent("Ollama native response was not a JSON object")]; + if (payload.error !== undefined && payload.error !== null) return [nativeErrorEvent(payload.error)]; + + const state: NativeStreamState = { + toolCalls: new Map(), + nextToolOrder: 0, + usage: usageFromNative(payload), + stopReason: stopReasonFromNative(payload.done_reason), + sawMessage: false, + terminal: false, + terminalError: false, + allowParallelToolCalls, + }; + // Same terminal contract as the NDJSON path — enforced BEFORE any actionable emission. The + // complete payload is already in memory and known invalid, so partial text and tool calls from + // it are suppressed along with the terminal: a truncated upstream reply must never be + // mistaken for a finished turn, and tool calls parsed out of one must never execute. + if (payload.done !== true) { + state.terminalError = true; + const reason = payload.done === undefined + ? "Ollama native response did not include done:true" + : payload.done === false + ? "Ollama native response reported done:false" + : "Ollama native response done flag was not boolean"; + return [malformedNativeEvent(reason, state.usage)]; + } + try { + const events = nativeMessageEvents(nativeBodyMessage(payload.message), state, budget); + events.push(...flushNativeStreamToolCalls(state, issuedToolCallIds)); + events.push({ type: "done", ...(state.usage ? { usage: state.usage } : {}), ...(state.stopReason ? { stopReason: state.stopReason } : {}) }); + state.terminal = true; + return events; + } catch (error) { + const events = isTranslatorBudgetExceededError(error) + ? [translationBudgetEvent(state.usage)] + : [malformedNativeEvent(error instanceof Error ? error.message : "Malformed Ollama native response", state.usage)]; + state.terminalError = true; + return events; + } finally { + releaseNativeStateBuffers(state, budget); + } +} + +function formatNativeErrorBody(status: number, _headers: Headers, payloadText: string): string { + let parsed: unknown; + try { + parsed = JSON.parse(payloadText); + } catch { + return status === 401 || status === 403 + ? "Ollama authentication failed" + : status === 404 + ? "Ollama native endpoint or model was not found" + : status === 429 + ? "Ollama rate limit was exceeded" + : status >= 500 + ? "Ollama native upstream failed" + : ""; + } + const detail = errorDetail(parsed); + if (detail) return redactSecretString(detail).slice(0, 400); + return status === 401 || status === 403 + ? "Ollama authentication failed" + : status === 404 + ? "Ollama native endpoint or model was not found" + : status === 429 + ? "Ollama rate limit was exceeded" + : status >= 500 + ? "Ollama native upstream failed" + : ""; +} + +function buildHeaders( + provider: OcxProviderConfig, + endpointKind: OllamaNativeEndpointKind, +): { headers: Record; hasCredential: boolean } { + if (provider.authMode === "forward") { + throw new Error("ollama-native does not support forwarded caller credentials"); + } + const hasApiKey = typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0; + const local = endpointKind === "local" || provider.authMode === "local"; + const plaintextRemote = endpointKind === "custom" && new URL(provider.baseUrl).protocol === "http:"; + // A copied provider row can carry credential headers even when apiKey is empty, and a + // key-optional custom row would otherwise ship them to a plaintext remote. Detect them with + // the shared credential-bearing name authority instead of a narrower local list. + const credentialHeaders = Object.keys(provider.headers ?? {}).filter(key => + SENSITIVE_KEY_PATTERN.test(key.trim()), + ); + if (plaintextRemote && (hasApiKey || credentialHeaders.length > 0)) { + throw new Error( + "ollama-native refuses to send credentials over plaintext non-loopback HTTP" + + (hasApiKey ? "" : ` (credential headers: ${credentialHeaders.join(", ")})`), + ); + } + const hasCredential = hasApiKey; + const requiresCredential = !local && (provider.authMode === undefined || provider.authMode === "key" || provider.authMode === "oauth"); + if (requiresCredential && !hasCredential && !provider.keyOptional) { + throw new Error("ollama-native cloud/custom endpoint requires a non-empty API credential"); + } + + // Same precedence as openAIChatTransport(): the generated Bearer is laid down FIRST and + // provider.headers are applied LAST, so an explicitly configured Authorization wins. Collision + // handling is case-insensitive and leaves exactly ONE effective credential spelling on the wire. + const headers: Record = { "Content-Type": "application/json" }; + if (!local && hasCredential) headers.Authorization = `Bearer ${provider.apiKey!.trim()}`; + for (const [key, value] of Object.entries(provider.headers ?? {})) { + // Loopback/local targets get no credentials at all — not even ones the row already carried — + // so a shared provider object cannot leak a shared credential to a local endpoint. + if (local && SENSITIVE_KEY_PATTERN.test(key.trim())) continue; + const lower = key.toLowerCase(); + for (const existing of Object.keys(headers)) { + if (existing.toLowerCase() === lower && existing !== key) delete headers[existing]; + } + headers[key] = value; + } + // Diagnostics carry the FACT that a credential is attached, never any header value. + return { + headers, + hasCredential: !local && (hasCredential + || Object.keys(provider.headers ?? {}).some(k => SENSITIVE_KEY_PATTERN.test(k.trim()))), + }; +} + +function replaceLiveBuffer( + budget: TranslatorBudget, + previousBytes: number, + nextBytes: number, +): void { + // Release BEFORE reserving: the retained bound tracks what is actually in memory, so growing + // the residual never transiently charges old + new together. + if (previousBytes > 0) budget.releaseRetained(previousBytes, { kind: "live_transient" }); + if (nextBytes > TRANSLATOR_MAX_SSE_EVENT_BYTES) { + throw new TranslatorBudgetExceededError("live_transient", TRANSLATOR_MAX_SSE_EVENT_BYTES); + } + if (nextBytes > 0) { + const reservation = budget.reserveTransient(nextBytes, { kind: "live_transient" }); + reservation.commitRetained(); + } +} + +async function readWithAbort( + reader: ReadableStreamDefaultReader, + signal: AbortSignal | undefined, +): Promise { + if (!signal) return await reader.read() as NativeReadResult; + if (signal.aborted) throw signal.reason; + const read = reader.read(); + void read.catch(() => undefined); + let rejectAbort: ((reason: unknown) => void) | undefined; + const aborted = new Promise((_resolve, reject) => { rejectAbort = reject; }); + const onAbort = () => rejectAbort?.(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + try { + const result = await Promise.race([read, aborted]); + if (signal.aborted) throw signal.reason; + return result as NativeReadResult; + } finally { + signal.removeEventListener("abort", onAbort); + } +} + +function streamState(allowParallelToolCalls = true): NativeStreamState { + return { + toolCalls: new Map(), + nextToolOrder: 0, + sawMessage: false, + terminal: false, + terminalError: false, + allowParallelToolCalls, + }; +} + +function processNativeLine( + line: string, + state: NativeStreamState, + budget: TranslatorBudget, + issuedToolCallIds: Set, +): AdapterEvent[] { + const trimmed = line.trim(); + if (!trimmed) return []; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + state.terminal = true; + state.terminalError = true; + return [malformedNativeEvent("Ollama native stream contained malformed NDJSON")]; + } + if (!isRecord(parsed)) { + state.terminal = true; + state.terminalError = true; + return [malformedNativeEvent("Ollama native stream line was not a JSON object")]; + } + if (state.terminal) { + state.terminalError = true; + return [malformedNativeEvent("Ollama native stream emitted data after its terminal record")]; + } + if (parsed.error !== undefined && parsed.error !== null) { + state.terminal = true; + state.terminalError = true; + return [nativeErrorEvent(parsed.error, state.usage)]; + } + if (parsed.prompt_eval_count !== undefined || parsed.eval_count !== undefined) { + state.usage = usageFromNative(parsed) ?? state.usage; + } + if (parsed.done_reason !== undefined) state.stopReason = stopReasonFromNative(parsed.done_reason); + + const events: AdapterEvent[] = []; + if (parsed.message !== undefined) { + try { + events.push(...nativeMessageEvents(nativeBodyMessage(parsed.message), state, budget)); + } catch (error) { + if (isTranslatorBudgetExceededError(error)) throw error; + state.terminal = true; + state.terminalError = true; + return [malformedNativeEvent(error instanceof Error ? error.message : "Malformed Ollama native stream message", state.usage)]; + } + } + if (parsed.done !== undefined && typeof parsed.done !== "boolean") { + state.terminal = true; + state.terminalError = true; + return [malformedNativeEvent("Ollama native stream done flag was not boolean", state.usage)]; + } + if (parsed.done === true) { + state.terminal = true; + events.push(...flushNativeStreamToolCalls(state, issuedToolCallIds)); + events.push({ type: "done", ...(state.usage ? { usage: state.usage } : {}), ...(state.stopReason ? { stopReason: state.stopReason } : {}) }); + } + return events; +} + +async function* parseOllamaNativeStream( + response: Response, + budget: TranslatorBudget, + signal?: AbortSignal, + issuedToolCallIds?: Set, + allowParallelToolCalls = true, +): AsyncGenerator { + if (!response.body) { + yield malformedNativeEvent("Ollama native response had no body"); + return; + } + const reader = response.body.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + const encoder = new TextEncoder(); + const state = streamState(allowParallelToolCalls); + const issuedIds = issuedToolCallIds ?? new Set(); + let buffer = ""; + let bufferBytes = 0; + let mustCancel = false; + + const ingest = function* (text: string): Generator { + if (!text) return; + buffer += text; + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + // The safety bound applies to the genuinely retained residual — the incomplete NDJSON record + // still being assembled — and to each complete record below. It must never depend on the + // transport read size (one read may carry many valid records) nor transiently charge + // old + replacement together. On a ceiling violation the old reservation is left in place so + // the generator's finally releases exactly what is held. + const residualBytes = encoder.encode(buffer).byteLength; + if (residualBytes > TRANSLATOR_MAX_SSE_EVENT_BYTES) { + throw new TranslatorBudgetExceededError("live_transient", TRANSLATOR_MAX_SSE_EVENT_BYTES); + } + replaceLiveBuffer(budget, bufferBytes, residualBytes); + bufferBytes = residualBytes; + + for (const rawLine of lines) { + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + const lineBytes = encoder.encode(line).byteLength; + if (lineBytes > TRANSLATOR_MAX_SSE_EVENT_BYTES) { + throw new TranslatorBudgetExceededError("live_transient", TRANSLATOR_MAX_SSE_EVENT_BYTES); + } + if (lineBytes > 0) { + const reservation = budget.reserveTransient(lineBytes, { kind: "live_transient" }); + reservation.commitRetained(); + try { + // The Responses terminal guard may stop consuming immediately after it sees `done`. + const events = processNativeLine(line, state, budget, issuedIds); + yield* events; + } finally { + budget.releaseRetained(lineBytes, { kind: "live_transient" }); + } + } + if (state.terminal) { + return; + } + } + }; + + try { + while (true) { + const read = await readWithAbort(reader, signal); + if (read.done) break; + if (!read.value || read.value.byteLength === 0) continue; + yield* ingest(decoder.decode(read.value, { stream: true })); + if (state.terminal) { + mustCancel = true; + return; + } + } + yield* ingest(decoder.decode()); + if (!state.terminal && buffer.length > 0) { + const rawLine = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer; + const lineBytes = encoder.encode(rawLine).byteLength; + if (lineBytes > TRANSLATOR_MAX_SSE_EVENT_BYTES) throw new TranslatorBudgetExceededError("live_transient", TRANSLATOR_MAX_SSE_EVENT_BYTES); + try { + // The EOF record keeps its residual charge while it is parsed and consumed — releasing it + // first would drop the accounting before the record is translated (and let a near-limit + // record's tool arguments slip past the turn cap that the newline-terminated path pays). + const events = processNativeLine(rawLine, state, budget, issuedIds); + yield* events; + } finally { + replaceLiveBuffer(budget, bufferBytes, 0); + bufferBytes = 0; + } + } + if (!state.terminal) { + mustCancel = true; + const event = malformedNativeEvent( + state.sawMessage || state.toolCalls.size > 0 + ? "Ollama native stream ended before done:true" + : "Ollama native stream ended without a terminal record", + state.usage, + ); + state.terminalError = true; + yield event; + } else { + mustCancel = true; + } + } catch (error) { + mustCancel = true; + let event: AdapterEvent; + if (isTranslatorBudgetExceededError(error)) { + event = translationBudgetEvent(state.usage); + } else if (signal?.aborted) { + event = { type: "error", status: 499, message: "client closed request while reading Ollama native stream" }; + } else { + event = malformedNativeEvent("Ollama native stream could not be decoded", state.usage); + } + state.terminalError = true; + yield event; + } finally { + if (bufferBytes > 0) budget.releaseRetained(bufferBytes, { kind: "live_transient" }); + releaseNativeStateBuffers(state, budget); + if (mustCancel) { + try { await reader.cancel(); } catch { /* the upstream body may already be closed */ } + } + try { reader.releaseLock(); } catch { /* already released */ } + } +} + +async function parseOllamaNativeResponse( + response: Response, + budget: TranslatorBudget, + issuedToolCallIds: Set, + allowParallelToolCalls = true, +): Promise { + const bounded = await readBoundedResponseBytes(response, { maxBytes: TRANSLATOR_MAX_SSE_EVENT_BYTES }); + if (bounded.oversized) return [malformedNativeEvent("Ollama native response exceeded the safe body limit")]; + let payload: unknown; + try { + payload = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bounded.bytes)); + } catch { + return [malformedNativeEvent("Ollama native response was not valid JSON")]; + } + const retainedBytes = bounded.bytes.byteLength; + if (retainedBytes > 0) budget.chargeRetained(retainedBytes, { kind: "retained_collectors" }); + try { + const events = nativeEventsFromResponsePayload(payload, budget, issuedToolCallIds, allowParallelToolCalls); + try { + retainTranslatedEventBatch(events, budget); + } catch (error) { + if (isTranslatorBudgetExceededError(error)) return [translationBudgetEvent()]; + throw error; + } + return events; + } finally { + if (retainedBytes > 0) budget.releaseRetained(retainedBytes, { kind: "retained_collectors" }); + } +} + +export function createOllamaNativeAdapter(provider: OcxProviderConfig): ProviderAdapter { + let requestAbortSignal: AbortSignal | undefined; + let requestAllowsParallelToolCalls = true; + const issuedToolCallIds = new Set(); + return { + name: "ollama-native", + formatErrorBody: formatNativeErrorBody, + + buildRequest(parsed: OcxParsedRequest, incoming?: IncomingMeta): AdapterRequest { + requestAbortSignal = incoming?.abortSignal; + requestAllowsParallelToolCalls = parsed.options.parallelToolCalls !== false; + const url = ollamaNativeChatUrl(provider.baseUrl); + const endpointKind = ollamaNativeEndpointKind(provider.baseUrl); + const { headers, hasCredential } = buildHeaders(provider, endpointKind); + const messages = buildNativeMessages(parsed, issuedToolCallIds); + const tools = buildNativeTools(parsed); + const format = nativeFormat(parsed, endpointKind); + const options: Record = {}; + const maxOutputTokens = parsed.options.maxOutputTokens + ?? modelRecordValue(provider.modelMaxOutputTokens, parsed.modelId) + ?? provider.defaultMaxOutputTokens; + // Same gate semantics as the openai-chat adapter (modelInList on the provider lists). + // Ollama's native Options carries every one of these under its own spelling. + if (maxOutputTokens !== undefined) options.num_predict = maxOutputTokens; + if (parsed.options.temperature !== undefined + && !modelInList(provider.noTemperatureModels, parsed.modelId)) { + options.temperature = parsed.options.temperature; + } + if (parsed.options.topP !== undefined + && !modelInList(provider.noTopPModels, parsed.modelId)) { + options.top_p = parsed.options.topP; + } + if (parsed.options.stopSequences !== undefined) options.stop = parsed.options.stopSequences; + if (parsed.options.presencePenalty !== undefined + && !modelInList(provider.noPenaltyModels, parsed.modelId)) { + options.presence_penalty = parsed.options.presencePenalty; + } + if (parsed.options.frequencyPenalty !== undefined + && !modelInList(provider.noPenaltyModels, parsed.modelId)) { + options.frequency_penalty = parsed.options.frequencyPenalty; + } + + const think = nativeThink(provider, parsed); + const body: Record = { + model: wireModelId(provider, parsed.modelId), + messages, + stream: parsed.stream, + ...(think !== undefined ? { think } : {}), + ...(tools ? { tools } : {}), + ...(format !== undefined ? { format } : {}), + ...(Object.keys(options).length > 0 ? { options } : {}), + }; + const bodyJson = JSON.stringify(body); + debugProviderDiagnostic("ollama-native", "request", { + host: (() => { try { return new URL(url).host; } catch { return "upstream"; } })(), + model: body.model, + stream: parsed.stream, + messageCount: messages.length, + toolCount: tools?.length ?? 0, + hasCredential, + bodyBytes: new TextEncoder().encode(bodyJson).byteLength, + thinkingRequested: parsed.options.reasoning !== undefined, + }); + return { url, method: "POST", headers, body: bodyJson }; + }, + + parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator { + return parseOllamaNativeStream( + response, + budget, + requestAbortSignal, + issuedToolCallIds, + requestAllowsParallelToolCalls, + ); + }, + + async parseResponse(response: Response, budget: TranslatorBudget): Promise { + return await parseOllamaNativeResponse( + response, + budget, + issuedToolCallIds, + requestAllowsParallelToolCalls, + ); + }, + }; +} diff --git a/src/adapters/registry.ts b/src/adapters/registry.ts index e9f54bcb8a..81fdbf99a4 100644 --- a/src/adapters/registry.ts +++ b/src/adapters/registry.ts @@ -8,6 +8,7 @@ import { createGoogleAdapter } from "./google"; import { createKiroAdapter } from "./kiro"; import { createMimoFreeAdapter } from "./mimo-free"; import { createOpenAIChatAdapter } from "./openai-chat"; +import { createOllamaNativeAdapter } from "./ollama-native"; import { createResponsesPassthroughAdapter } from "./openai-responses"; import type { OcxProviderConfig } from "../types"; import { createAdapterTierMetadata } from "../providers/fastwire"; @@ -21,6 +22,7 @@ export interface AdapterFactoryContext { export type AdapterWire = | "command-code" | "openai-chat" + | "ollama-native" | "anthropic" | "openai-responses" | "google" @@ -62,6 +64,11 @@ export const ADAPTER_REGISTRY = { create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => withClinePassDeepSeekV4ToolReplayCompatibility(createOpenAIChatAdapter(provider)), }, + "ollama-native": { + wire: "ollama-native", + mutation: "codex-owned", + create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createOllamaNativeAdapter(provider), + }, anthropic: { wire: "anthropic", mutation: "codex-owned", diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 5ef9dffa2b..b5190d4162 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -71,6 +71,7 @@ import { type ProviderModelsApiItem, type ResolvedProviderModelDiscovery, } from "../../providers/model-discovery"; +import { applyConfiguredHeadersLast, fetchOllamaShowEnrichment, ollamaShowEnrichable } from "../../providers/ollama-show"; import upstreamModelsSnapshot from "../data/upstream-models.json"; import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } from "../../lib/admission"; @@ -1370,7 +1371,16 @@ async function fetchProviderModelsWithAuth( ); } const url = request.url; - const headers = materializeCapturedHeaders(request, apiKey); + let headers = materializeCapturedHeaders(request, apiKey); + // One Ollama authority contract: for canonical ollama-cloud/ollama-native rows, discovery + // (/v1/models), enrichment (/api/show) and inference (/api/chat) must all materialize the + // SAME effective credential/header authority. buildModelsRequest's generic tail writes the + // generated Bearer AFTER configured headers, but the native inference adapter applies + // provider.headers LAST (configured wins, case-insensitive collapse). Reapply the configured + // provider headers here so the whole Ollama request family shares that one authority. + if (ollamaShowEnrichable(name, prov)) { + headers = applyConfiguredHeadersLast(headers, prov.headers); + } const urlClass = new URL(url).hostname.endsWith("aiplatform.googleapis.com") ? "vertex-aiplatform" : "provider-models"; @@ -1500,13 +1510,40 @@ async function fetchProviderModelsWithAuth( return observed(models, "degraded"); } const items = extracted.items; + // Ollama Cloud enrichment: /v1/models carries no per-model context or capability metadata, + // so a newly announced id would otherwise publish generic defaults. /api/show fills that + // per model, fail-soft, bounded, and cached with this gather's result. Explicit configured + // metadata keeps its normal precedence (applyProviderConfigHints applies the discovered + // window only where exact config is absent, and the provider context cap still caps it). + const showEnrichment = ollamaShowEnrichable(name, prov) + ? await fetchOllamaShowEnrichment({ + headers, + discoveryUrl: request.url, + modelIds: items.map(m => m.id), + provider: prov, + }).catch(() => undefined) + : undefined; const live = items.map(m => { const ownedBy = boundedOwnedBy(m.owned_by); + // Precedence: the authoritative /v1/models row wins; /api/show fills only metadata the + // models-API row does not carry. applyProviderConfigHints then applies explicit + // configured metadata over both, and the provider context cap still caps the result. + const modelsApiHints = catalogHintsFromModelsApiItem(name, m); + const show = showEnrichment?.metadata.get(m.id); + const discoveredHints = { + ...modelsApiHints, + ...(modelsApiHints.contextWindow === undefined && show?.contextWindow !== undefined + ? { contextWindow: show.contextWindow } + : {}), + ...(modelsApiHints.inputModalities === undefined && show?.nativeVision === true + ? { inputModalities: ["text", "image"] as string[] } + : {}), + }; return applyProviderConfigHints(name, prov, { id: m.id, provider: name, ...(ownedBy ? { owned_by: ownedBy } : {}), - ...catalogHintsFromModelsApiItem(name, m), + ...discoveredHints, }, contextCap); }) .filter(m => shouldExposeProviderModel(name, m.id)); diff --git a/src/lib/redact.ts b/src/lib/redact.ts index 9f9bb4af44..ab82047aa3 100644 --- a/src/lib/redact.ts +++ b/src/lib/redact.ts @@ -1,6 +1,11 @@ export const REDACTED_SECRET = "[REDACTED]"; -const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn|exa[-_]?api[-_]?key)$/i; +/** + * Credential-bearing header/field names. Exported for transports that must refuse to send + * credentials over an unsafe channel (e.g. plaintext non-loopback HTTP) rather than + * re-deriving a narrower local list. + */ +export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn|exa[-_]?api[-_]?key)$/i; /** * Colon-labelled credential headers echoed back inside an error body diff --git a/src/providers/ollama-show.ts b/src/providers/ollama-show.ts new file mode 100644 index 0000000000..ba33b11323 --- /dev/null +++ b/src/providers/ollama-show.ts @@ -0,0 +1,311 @@ +/** + * Bounded Ollama Cloud `/api/show` metadata enrichment. + * + * `/v1/models` is the authoritative live ID roster, but it carries no per-model context or + * capability metadata, so a newly announced Ollama model (e.g. glm-5.3 during its rollout) + * would otherwise be advertised to Codex with generic defaults — a 1M-context model published + * at 128K, and native vision unknown. `/api/show` fills that gap for canonical Ollama Cloud + * destinations only. + * + * The show request reuses the discovery request's already-materialized captured headers + * (credential + configured-header precedence resolved by `buildModelsRequest`, not re-derived + * here) and executes through the same outbound-policy transport as discovery + * (`providerOutboundPost`: destination policy, DNS pinning, manual redirects, caller-owned + * executor). + * + * Failure is per model and fail-soft: any transport, status, redirect, size, parse, or timeout + * failure drops the enrichment for that one model and never touches other rows or the ID roster + * itself. Only evidence-backed fields are extracted; templates, licenses, and tokenizer + * payloads exist only inside the bounded body and are never projected into CatalogModel + * metadata. + */ +import { readBoundedResponseBytes } from "../lib/bounded-body"; +import { isCanonicalOllamaCloudUrl } from "../adapters/ollama-native-url"; +import { providerOutboundPost, providerRedirectError } from "../lib/provider-outbound"; + +/** Hard per-response bound: cloud /api/show metadata is small; anything larger is discarded. */ +const SHOW_MAX_RESPONSE_BYTES = 256 * 1024; +/** + * Aggregate deadline for the ENTIRE show-enrichment phase, independent of roster size and of + * the generic discovery row limit. A stalled endpoint must never turn a successful /v1/models + * discovery into a multi-minute catalog stall. + */ +const SHOW_AGGREGATE_DEADLINE_MS = 12_000; +/** Per-request timeout: a single show request never outlives this, deadline or not. */ +const SHOW_REQUEST_TIMEOUT_MS = 8_000; +/** + * Show-specific request cap, independent of the generic 2000-row discovery hard limit. + * Conservative for the current Ollama Cloud roster (~19 ids) while leaving room for growth; + * ids beyond the cap simply stay on the existing safe fallback metadata. + */ +const SHOW_REQUEST_CAP = 48; +/** Concurrent /api/show requests never exceed this, regardless of roster size. */ +const SHOW_MAX_CONCURRENCY = 4; +/** A discovered context window must be a plausible positive integer, not arbitrary data. */ +const SHOW_MAX_CONTEXT_LENGTH = 16 * 1024 * 1024; + +export interface OllamaShowMetadata { + /** Trained context length reported by the model's own architecture metadata. */ + contextWindow?: number; + /** Native vision capability reported by Ollama (`capabilities` includes "vision"). */ + nativeVision?: boolean; +} + +export interface OllamaShowEnrichmentResult { + metadata: Map; + /** /api/show requests issued (bounded by the request cap and the roster). */ + showRequests: number; + /** True when the aggregate deadline stopped the enrichment early. */ + deadlineHit: boolean; +} + +export interface OllamaShowEnrichmentOptions { + /** The already-materialized captured discovery headers (credential + configured precedence). */ + headers: Record; + /** The discovery request URL actually captured for this provider (same origin is used). */ + discoveryUrl: string; + modelIds: readonly string[]; + /** Show-specific request cap, independent of the generic discovery row limit. */ + showRequestCap?: number; + /** Aggregate wall-clock deadline for the whole enrichment phase (injectable for tests). */ + deadlineMs?: number; + /** Per-request timeout (injectable for deterministic tests). */ + requestTimeoutMs?: number; + /** Outbound config for the policy-checked transport (must carry the test executor). */ + provider: { + baseUrl: string; + adapter?: string; + fetch?: typeof fetch; + }; +} + +/** + * Scope gate: enrichment runs ONLY for the canonical Ollama Cloud destination. Custom + * ollama-native providers (self-hosted or renamed rows) and every unrelated provider are + * untouched, so `/api/show` behavior can never widen into a generic provider surface. + */ +export function ollamaShowEnrichable( + providerName: string, + provider: { adapter?: string; baseUrl?: string }, +): boolean { + if (providerName !== "ollama-cloud") return false; + if (provider.adapter !== "ollama-native") return false; + const baseUrl = provider.baseUrl; + if (typeof baseUrl !== "string" || !baseUrl) return false; + return isCanonicalOllamaCloudUrl(baseUrl); +} + +/** + * Extract only evidence-backed catalog metadata from an `/api/show` payload. The input is not + * mutated; templates, licenses, and tokenizer payloads exist only inside the bounded parse and + * are never projected into CatalogModel metadata. + */ +export function ollamaShowMetadataFromPayload(payload: unknown): OllamaShowMetadata | undefined { + if (payload === null || typeof payload !== "object" || Array.isArray(payload)) return undefined; + const raw = payload as Record; + const modelInfo = raw.model_info; + let contextWindow: number | undefined; + const info = modelInfo !== null && typeof modelInfo === "object" && !Array.isArray(modelInfo) + ? modelInfo as Record + : undefined; + if (info !== undefined) { + // Prefer the context length named by the model's own architecture, then fall back to a + // unique `*.context_length` key only when the architecture spelling is absent or ambiguous. + // The architecture key is FILTERED while collecting fallback candidates — the parsed input is + // never mutated. + const architecture = typeof info["general.architecture"] === "string" + ? (info["general.architecture"] as string) + : undefined; + const architectureKey = architecture !== undefined ? `${architecture}.context_length` : undefined; + if (architectureKey !== undefined) { + const value = info[architectureKey]; + if (isPlausibleContextLength(value)) contextWindow = value; + } + if (contextWindow === undefined) { + const candidates = Object.entries(info) + .filter(([key, value]) => + key.endsWith(".context_length") + && key !== architectureKey + && isPlausibleContextLength(value)) + .map(([, value]) => value as number); + if (candidates.length === 1) contextWindow = candidates[0]; + } + } + + const capabilities = Array.isArray(raw.capabilities) + ? raw.capabilities.filter((c): c is string => typeof c === "string") + : undefined; + const nativeVision = capabilities?.includes("vision") === true; + + if (contextWindow === undefined && capabilities === undefined) return undefined; + return { + ...(contextWindow !== undefined ? { contextWindow } : {}), + ...(capabilities !== undefined ? { nativeVision } : {}), + }; +} + +function isPlausibleContextLength(value: unknown): value is number { + return typeof value === "number" + && Number.isSafeInteger(value) + && value > 0 + && value <= SHOW_MAX_CONTEXT_LENGTH; +} + +/** + * Reapply the configured provider headers LAST over an already-materialized header map, + * case-insensitively: a configured Authorization/authorization replaces the generated Bearer + * (exactly one effective credential spelling survives), matching the native /api/chat adapter's + * precedence (generated Bearer first, provider.headers last). Non-credential configured headers + * are likewise reapplied so an explicit operator spelling wins. + */ +export function applyConfiguredHeadersLast( + headers: Record, + providerHeaders: Record | undefined, +): Record { + const out: Record = { ...headers }; + for (const [key, value] of Object.entries(providerHeaders ?? {})) { + const lower = key.toLowerCase(); + for (const existing of Object.keys(out)) { + if (existing.toLowerCase() === lower && existing !== key) delete out[existing]; + } + out[key] = value; + } + return out; +} + +/** + * Show headers for the JSON POST: force Content-Type case-insensitively (the endpoint has a + * JSON body), leave every other captured header — including configured Authorization/auth + * spellings — exactly as the materialized discovery request produced them. + */ +export function showHeadersFromCaptured( + capturedHeaders: Record, +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(capturedHeaders)) { + if (key.toLowerCase() === "content-type") continue; + out[key] = value; + } + out["Content-Type"] = "application/json"; + return out; +} + +/** + * Enrich discovered Ollama Cloud ids through `POST /api/show`, executed through the same + * outbound-policy transport as discovery (`providerOutboundPost`) with the already-materialized + * captured headers — the show request never manufactures its own auth contract. + * + * Fail-soft per model: transport errors, non-2xx responses, redirects (never followed, so the + * credential can never reach another origin), oversized payloads, malformed data, and deadline + * aborts each skip that model's enrichment without affecting other rows or the success of + * discovery itself. The aggregate deadline stops new launches and aborts active work; partial + * results are returned and unenriched ids stay on the existing safe fallback metadata. + */ +export async function fetchOllamaShowEnrichment( + options: OllamaShowEnrichmentOptions, +): Promise { + const { + headers: capturedHeaders, + discoveryUrl, + modelIds, + showRequestCap = SHOW_REQUEST_CAP, + deadlineMs = SHOW_AGGREGATE_DEADLINE_MS, + requestTimeoutMs = SHOW_REQUEST_TIMEOUT_MS, + provider, + } = options; + // Late-worker isolation: workers that complete or throw after the phase returns mutate ONLY + // the internal map; the returned metadata is the EXACT snapshot the phase promise resolved + // with — never a re-snapshot of the mutable map after resolution. + const metadata = new Map(); + + // Same origin as the materialized discovery request, so /api/show can never point at another + // destination than the one the credential was already materialized for. + const showUrl = new URL("/api/show", new URL(discoveryUrl).origin).toString(); + const showHeaders = showHeadersFromCaptured(capturedHeaders); + + const deadlineAbort = new AbortController(); + const requestSignal = () => AbortSignal.any([ + AbortSignal.timeout(requestTimeoutMs), + deadlineAbort.signal, + ]); + + const ids = modelIds.slice(0, Math.min(modelIds.length, showRequestCap)); + let cursor = 0; + let active = 0; + let showRequests = 0; + let deadlineHit = false; + let settled = false; + let deadlineTimer: ReturnType | undefined; + let resolvePhase: ((snapshot: Map) => void) | undefined; + + // The phase-finishing path: stops launches (settled guard), takes the metadata snapshot at + // this exact moment, and resolves the phase promise with it. The caller returns THAT resolved + // snapshot, so late worker settlement can never change the returned metadata. + const finish = (deadline: boolean): void => { + if (settled) return; + settled = true; + deadlineHit = deadline; + if (deadlineTimer !== undefined) clearTimeout(deadlineTimer); + const snapshot = new Map(metadata); + resolvePhase?.(snapshot); + }; + + const result = await new Promise((resolve) => { + resolvePhase = (snapshot) => resolve({ + metadata: snapshot, + showRequests, + deadlineHit, + }); + + // The aggregate deadline TIMER ITSELF is the return bound: it prevents further launches + // (settled guard in pump), aborts active workers, and resolves the phase IMMEDIATELY. It + // never relies on a worker settling, its finally block, the per-request timeout, or another + // pump() call. Declared after finish so the callback has no TDZ reference. + deadlineTimer = setTimeout(() => { + if (settled) return; + deadlineAbort.abort(new DOMException("ollama /api/show aggregate deadline", "TimeoutError")); + finish(true); + }, deadlineMs); + + const pump = () => { + if (settled) return; + while (active < SHOW_MAX_CONCURRENCY && cursor < ids.length) { + const id = ids[cursor++]; + active += 1; + showRequests += 1; + void (async () => { + try { + const res = await providerOutboundPost("ollama-cloud", provider, showUrl, { + headers: showHeaders, + body: JSON.stringify({ model: id }), + signal: requestSignal(), + }); + const redirectError = await providerRedirectError(res, showUrl); + // Redirect handling: never follow — the credential must never reach another origin. + // A redirected or non-2xx show response is a per-model failure, not a retry. + if (redirectError || !res.ok || ![200, 201].includes(res.status)) return; + const bounded = await readBoundedResponseBytes(res, { maxBytes: SHOW_MAX_RESPONSE_BYTES }); + if (bounded.oversized) return; + let payload: unknown; + try { + payload = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bounded.bytes)); + } catch { + return; + } + const parsed = ollamaShowMetadataFromPayload(payload); + if (parsed) metadata.set(id, parsed); + } catch { + // Fail-soft: this model simply stays unenriched. Late settlement after the phase has + // returned only touches the internal map — the caller holds the exact snapshot. + } finally { + active -= 1; + pump(); + } + })(); + } + if (cursor >= ids.length && active === 0) finish(false); + }; + pump(); + }); + return result; +} diff --git a/src/providers/registry.ts b/src/providers/registry.ts index a8cca87828..ad4f809448 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -2554,13 +2554,23 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ { id: "ollama-cloud", label: "Ollama Cloud", + // The upstream /v1 spelling is deliberately unchanged: ollamaNativeChatUrl() normalizes it + // to /api/chat, and live model discovery declares its own /v1/models path against the origin, + // so the native transport needs no base-URL edit here or in the free-provider directory. baseUrl: "https://ollama.com/v1", - adapter: "openai-chat", + // The native transport must be declared HERE, not in configuration. routedProviderConfig() + // overwrites provider.adapter with the registry adapter for every row whose transport + // matches, so a config-level adapter is silently discarded. + adapter: "ollama-native", authKind: "key", dashboardUrl: "https://ollama.com/settings/keys", // Live IDs verified 2026-07-10; qwen3-coder:480b retires 2026-07-15. models: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "deepseek-v4-pro", "qwen3-coder:480b", "gpt-oss:120b", "kimi-k2.6", "minimax-m3", "qwen3.5:397b", "gemma4:31b"], defaultModel: "glm-5.3", + // Owner-audited exact outage fallback: these current Ollama Cloud GLM-5.3 rows have + // 1,048,576-token context windows. Live discovery and successful /api/show enrichment keep + // their existing precedence; these values prevent a failed show from becoming generic. + modelContextWindows: { "glm-5.3": 1_048_576, "glm-5.3-flash": 1_048_576 }, noVisionModels: [ // glm-5.3-flash is absent on purpose: native VLM // (docs.z.ai/guides/vlm/glm-5.3-flash), so its images skip the sidecar. @@ -2570,6 +2580,19 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ "deepseek-v4-pro", "deepseek-v4-flash", "gpt-oss", "qwen3-coder:480b", ], + // Ollama's native chat API has no `text.verbosity` equivalent and the ollama-native adapter + // never emits one, so a routed row must not inherit the Codex template's verbosity picker. + // Provider-wide rather than per-model: this catalog is discovery-authoritative, so ids that + // arrive later from live discovery must opt out too (the live-discovery gap closed by #2578). + supportsVerbosity: false, + // Live model discovery: Ollama serves the standard OpenAI-style data[] envelope at /v1/models, + // so the generic discovery pipeline needs no special-casing. The path is spelled against the + // ORIGIN (model-discovery resolves a leading-slash path against base.origin). A discovery + // spec is REQUIRED here: without one the pipeline probes https://ollama.com/models, which + // 307-redirects to /search and discovery falls back to the configured list. + modelDiscovery: { + path: "/v1/models", + }, }, // FREEZE 2026-07-10: codestral-latest is unconfirmed behind auth. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. { id: "mistral", label: "Mistral", baseUrl: "https://api.mistral.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://console.mistral.ai/api-keys", defaultModel: "codestral-latest" }, diff --git a/tests/adapter-buffered-tool-conformance.test.ts b/tests/adapter-buffered-tool-conformance.test.ts index 6c2f11b928..3472afdd67 100644 --- a/tests/adapter-buffered-tool-conformance.test.ts +++ b/tests/adapter-buffered-tool-conformance.test.ts @@ -16,6 +16,7 @@ const PATCH = `*** Begin Patch const WIRE_MODELS: Record = { "openai-chat": "grok-4.6", + "ollama-native": "glm-5.3-flash", anthropic: "claude-haiku-4-5", google: "gemini-3.5-flash", "command-code": "deepseek/deepseek-v4-flash", @@ -27,6 +28,7 @@ const WIRE_MODELS: Record = { function providerFixture(adapterId: string, wire: AdapterWire): OcxProviderConfig { const baseUrls: Record = { "openai-chat": "https://api.x.ai/v1", + "ollama-native": "https://ollama.com/v1", anthropic: "https://api.anthropic.com", google: "https://generativelanguage.googleapis.com", "command-code": "https://api.commandcode.ai", @@ -87,6 +89,22 @@ function bufferedResponse(wire: AdapterWire, wireName = "apply_patch"): Response usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, })); } + if (wire === "ollama-native") { + // Ollama's native /api/chat buffered envelope: one message object, arguments as a JSON + // object rather than the OpenAI-style encoded string, and `done` instead of finish_reason. + return new Response(JSON.stringify({ + model: "glm-5.3-flash", + message: { + role: "assistant", + content: "", + tool_calls: [{ type: "function", id: "call_buffered_patch", function: { name: wireName, arguments: args } }], + }, + done: true, + done_reason: "stop", + prompt_eval_count: 1, + eval_count: 1, + })); + } if (wire === "anthropic") { return new Response(JSON.stringify({ content: [{ type: "tool_use", id: "call_buffered_patch", name: wireName, input: args }], diff --git a/tests/adapter-registry-authority.test.ts b/tests/adapter-registry-authority.test.ts index fa10f27984..1bbfc28c40 100644 --- a/tests/adapter-registry-authority.test.ts +++ b/tests/adapter-registry-authority.test.ts @@ -12,6 +12,7 @@ import { withTestTranslatorBudget } from "./helpers/translator-budget"; const EXPECTED_ADAPTER_NAMES = { "command-code": "command-code", "openai-chat": "openai-chat", + "ollama-native": "ollama-native", anthropic: "anthropic", "openai-responses": "openai-responses", google: "google", @@ -29,7 +30,11 @@ function provider(adapter: string): OcxProviderConfig { // adapter accepts the placeholder URL. baseUrl: adapter === "mimo-free" ? "https://api.xiaomimimo.com/api/free-ai/openai/chat" - : "https://example.invalid/v1", + // ollama-native refuses a bare /v1 path on a host it does not recognise, rather than + // guessing that an arbitrary destination speaks Ollama's compatibility surface. + : adapter === "ollama-native" + ? "https://example.invalid/api" + : "https://example.invalid/v1", authMode: "key", apiKey: "test-key", defaultMaxOutputTokens: 4096, diff --git a/tests/adapter-tool-conformance.test.ts b/tests/adapter-tool-conformance.test.ts index f70374ee47..a3a6ae2800 100644 --- a/tests/adapter-tool-conformance.test.ts +++ b/tests/adapter-tool-conformance.test.ts @@ -27,6 +27,7 @@ const EXEC_DESCRIPTION = const WIRE_MODELS: Record = { "openai-chat": "grok-4.6", + "ollama-native": "glm-5.3-flash", anthropic: "claude-haiku-4-5", google: "gemini-3.5-flash", "command-code": "deepseek/deepseek-v4-flash", @@ -38,6 +39,7 @@ const WIRE_MODELS: Record = { function providerFixture(adapterId: string, wire: AdapterWire): OcxProviderConfig { const baseUrls: Record = { "openai-chat": "https://api.x.ai/v1", + "ollama-native": "https://ollama.com/v1", anthropic: "https://api.anthropic.com", google: "https://generativelanguage.googleapis.com", "command-code": "https://api.commandcode.ai", @@ -212,7 +214,8 @@ async function outbound(adapterId: string, parsed: OcxParsedRequest): Promise; - if (wire === "openai-chat") { + if (wire === "openai-chat" || wire === "ollama-native") { + // Ollama's native /api/chat declares tools with the same {type,function:{name}} shape. const tools = parsed.tools as Array<{ function?: { name?: string } }> | undefined; return (tools ?? []).flatMap(tool => typeof tool.function?.name === "string" ? [tool.function.name] : []); } @@ -477,6 +480,14 @@ describe("registry-derived routed tool conformance", () => { await expect(outbound(adapterId, parsed)).rejects.toThrow("Kiro supports only automatic tool choice or tool_choice:none"); continue; } + if (contract.wire === "ollama-native") { + // Ollama's native chat API has no tool_choice field, so a "required" selector cannot be + // enforced on the wire. The adapter refuses rather than advertising an unenforced choice. + await expect(outbound(adapterId, parsed)).rejects.toThrow( + "ollama-native does not support required or exact named tool_choice", + ); + continue; + } const body = await outbound(adapterId, parsed); expect(advertisedToolNames(contract.wire, body), adapterId).toHaveLength(0); } diff --git a/tests/helpers/adapter-conformance/wire-drivers.ts b/tests/helpers/adapter-conformance/wire-drivers.ts index 3abc7855eb..979d1d4780 100644 --- a/tests/helpers/adapter-conformance/wire-drivers.ts +++ b/tests/helpers/adapter-conformance/wire-drivers.ts @@ -162,6 +162,44 @@ const openAiChatDriver: ToolWireDriver = { streamingToolCall: openAiChatToolCall, }; +const ollamaNativeDriver: ToolWireDriver = { + observeOutbound: observeHttpOutbound, + extractWireToolName(body, canonicalName) { + const parsed = JSON.parse(body) as { tools?: Array<{ function?: { name?: string } }> }; + const match = parsed.tools?.find(tool => tool.function?.name?.includes(canonicalName))?.function?.name; + return requireWireToolName(match, canonicalName, "ollama-native"); + }, + streamingToolCall(wireName, wrappedArguments) { + // Ollama streams NDJSON, not SSE, and delivers each tool call whole: `arguments` is a JSON + // object rather than a string fragmented across frames, so there is nothing to split here. + const frames = [ + { + model: "glm-5.3-flash", + message: { + role: "assistant", + content: "", + tool_calls: [{ + type: "function", + id: "call_patch", + function: { name: wireName, arguments: JSON.parse(wrappedArguments) as Record }, + }], + }, + done: false, + }, + { + model: "glm-5.3-flash", + message: { role: "assistant", content: "" }, + done: true, + done_reason: "stop", + prompt_eval_count: 1, + eval_count: 1, + }, + ]; + const ndjson = frames.map(frame => `${JSON.stringify(frame)}\n`).join(""); + return new Response(ndjson, { headers: { "content-type": "application/x-ndjson" } }); + }, +}; + const anthropicDriver: ToolWireDriver = { observeOutbound: observeHttpOutbound, extractWireToolName(body, canonicalName) { @@ -229,6 +267,7 @@ const responsesDriver: ToolWireDriver = { export const TOOL_WIRE_DRIVERS = { "openai-chat": openAiChatDriver, + "ollama-native": ollamaNativeDriver, anthropic: anthropicDriver, google: googleDriver, "command-code": commandCodeDriver, diff --git a/tests/ollama-native-parser.test.ts b/tests/ollama-native-parser.test.ts new file mode 100644 index 0000000000..589d84e3fc --- /dev/null +++ b/tests/ollama-native-parser.test.ts @@ -0,0 +1,529 @@ +import { describe, expect, test } from "bun:test"; +import { createOllamaNativeAdapter } from "../src/adapters/ollama-native"; +import { ollamaNativeChatUrl } from "../src/adapters/ollama-native-url"; +import { createTestTranslatorBudget } from "./helpers/translator-budget"; +import type { AdapterEvent } from "../src/types"; +import type { OcxParsedRequest, OcxProviderConfig } from "../src/types"; + +/** + * Parser/request contract tests for the native Ollama transport. + * Structural-receipt tooling deliberately does not exist in production code; these tests use the + * public adapter surface (buildRequest / parseStream / parseResponse) and plain fixtures only. + */ + +function provider(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "ollama-native", + baseUrl: "https://ollama.com/v1", + authMode: "key", + apiKey: "test-key-not-a-real-credential", + liveModels: false, + models: ["glm-5.3-flash"], + ...overrides, + } as OcxProviderConfig; +} + +function parsedWith( + messages: unknown[], + options: Record = {}, + modelId = "glm-5.3-flash", +): OcxParsedRequest { + return { modelId, stream: true, options, context: { messages } } as unknown as OcxParsedRequest; +} + +function ndjsonResponse(frames: unknown[]): Response { + const body = frames.map(frame => `${JSON.stringify(frame)}\n`).join(""); + return new Response(body, { headers: { "content-type": "application/x-ndjson" } }); +} + +function frame(message: Record, done: boolean, extra: Record = {}): Record { + return { model: "glm-5.3-flash", message, done, ...extra }; +} + +async function collect(adapter: ReturnType, response: Response): Promise { + const budget = createTestTranslatorBudget(); + const out: AdapterEvent[] = []; + for await (const event of adapter.parseStream(response, budget)) out.push(event); + return out; +} + +describe("ollama-native — observer-free streaming", () => { + test("no structural-receipt machinery exists in the module surface", async () => { + const mod = await import("../src/adapters/ollama-native") as unknown as Record; + for (const name of [ + "setOllamaNativeObservationSink", + "createOllamaNativeObservationBuffer", + ]) { + expect(mod[name], name).toBeUndefined(); + } + }); + + test("a ~30 MiB valid line split across reads is delivered intact", async () => { + // The safety bound applies to the assembled RECORD, not to read boundaries. The old + // accounting committed old + replacement together, so growth steps double-charged. + const line = "x".repeat(30 * 1024 * 1024); // 30 MiB: under the 32 MiB record ceiling... + // First read: 18 MiB of the giant line (incomplete), second: the remaining ~12 MiB + newline. + // Under old+replacement charging this transiently holds 18 + 30 = 48 MiB against the 32 MiB + // turn cap and the turn dies, even though the finished record is perfectly valid. + const giantLine = `${JSON.stringify({ model: "m", message: { role: "assistant", content: line }, done: true, done_reason: "stop" })}\n`; + const read1 = giantLine.slice(0, 18 * 1024 * 1024); + const read2 = giantLine.slice(18 * 1024 * 1024); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(read1)); + controller.enqueue(new TextEncoder().encode(read2)); + controller.close(); + }, + }); + const adapter = createOllamaNativeAdapter(provider()); + const budget = createTestTranslatorBudget(); + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(new Response(body), budget)) events.push(event); + const text = events.filter(e => e.type === "text_delta").reduce((s, e) => s + (e as { text: string }).text.length, 0); + expect(text).toBe(line.length); + expect(events.at(-1)?.type).toBe("done"); + }); + + test("one >32 MiB read carrying individually-valid smaller records is accepted", async () => { + // Nine complete 4 MiB records arrive in ONE transport read of 36 MiB. The old accounting + // reserved the whole READ before splitting it and rejected at 36 MiB even though every record + // was individually valid. The record is the safety unit, not the read. + const line = "y".repeat(4 * 1024 * 1024); + const frames: string[] = []; + for (let i = 0; i < 8; i++) { + frames.push(`${JSON.stringify({ model: "m", message: { role: "assistant", content: line }, done: false })} +`); + } + frames.push(`${JSON.stringify({ model: "m", message: { role: "assistant", content: "" }, done: true, done_reason: "stop" })} +`); + const oneRead = frames.join(""); + expect(new TextEncoder().encode(oneRead).byteLength).toBeGreaterThan(32 * 1024 * 1024); + const body = new ReadableStream({ + start(controller) { controller.enqueue(new TextEncoder().encode(oneRead)); controller.close(); }, + }); + + const adapter = createOllamaNativeAdapter(provider()); + const budget = createTestTranslatorBudget(); + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(new Response(body), budget)) events.push(event); + const deltas = events.filter(e => e.type === "text_delta"); + expect(deltas).toHaveLength(8); + expect(deltas.reduce((s, e) => s + (e as { text: string }).text.length, 0)).toBe(8 * line.length); + expect(events.at(-1)?.type).toBe("done"); + }); + + test("a single NDJSON record over the ceiling fails with the translation buffer limit", async () => { + // 33 MiB in ONE record: the record itself exceeds the 32 MiB ceiling and must fail closed. + const line = "z".repeat(33 * 1024 * 1024); + const response = new Response( + `${JSON.stringify({ model: "m", message: { role: "assistant", content: line }, done: true })}\n`, + { headers: { "content-type": "application/x-ndjson" } }, + ); + const adapter = createOllamaNativeAdapter(provider()); + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(response, createTestTranslatorBudget())) events.push(event); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", code: "translation_buffer_limit" }); + }); + + test("retained high-water tracks the in-flight record/residual, not read size", async () => { + // Same single 32 MiB read as above: the in-flight unit is one ~4 MiB record plus a near-zero + // residual, so the high-water mark must stay near one record. The removed implementation + // committed the whole read (≈32 MiB) before splitting it, and its release-after-reserve + // ordering transiently double-charged growth steps. + const line = "w".repeat(4 * 1024 * 1024); + const frames: string[] = []; + for (let i = 0; i < 8; i++) { + frames.push(`${JSON.stringify({ model: "m", message: { role: "assistant", content: line }, done: false })} +`); + } + frames.push(`${JSON.stringify({ model: "m", message: { role: "assistant", content: "" }, done: true, done_reason: "stop" })} +`); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(frames.join(""))); + controller.close(); + }, + }); + + const adapter = createOllamaNativeAdapter(provider()); + const budget = createTestTranslatorBudget(); + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(new Response(body), budget)) events.push(event); + expect(events.at(-1)?.type).toBe("done"); + const snapshot = budget.snapshot(); + expect(snapshot.highWaterBytes).toBeLessThan(line.length * 2); + }); + + test("thinking deltas stream interleaved with content and a terminal", async () => { + const frames = [ + { model: "m", message: { role: "assistant", thinking: "step one" }, done: false }, + { model: "m", message: { role: "assistant", content: "hello" }, done: false }, + { model: "m", message: { role: "assistant", content: "" }, done: true, done_reason: "stop", eval_count: 7 }, + ]; + const adapter = createOllamaNativeAdapter(provider()); + const budget = createTestTranslatorBudget(); + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(ndjsonResponse(frames), budget)) events.push(event); + expect(events.map(e => e.type)).toEqual(["reasoning_raw_delta", "text_delta", "done"]); + expect(events[0]).toMatchObject({ type: "reasoning_raw_delta", text: "step one" }); + expect(events[1]).toMatchObject({ type: "text_delta", text: "hello" }); + expect(events[2]).toMatchObject({ type: "done", stopReason: "stop", usage: { outputTokens: 7 } }); + }); + + test("usage and done_reason map onto the terminal event", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const budget = createTestTranslatorBudget(); + const frames = [ + { model: "m", message: { role: "assistant", content: "hi" }, done: true, done_reason: "length", prompt_eval_count: 11, eval_count: 5 }, + ]; + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(ndjsonResponse(frames), budget)) events.push(event); + const done = events.at(-1); + expect(done).toMatchObject({ + type: "done", + stopReason: "max_tokens", + usage: { inputTokens: 11, outputTokens: 5 }, + }); + }); + + test("malformed NDJSON fails closed with a parser error, never a done", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const budget = createTestTranslatorBudget(); + const response = new Response("{not json\n", { headers: { "content-type": "application/x-ndjson" } }); + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(response, budget)) events.push(event); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", code: "invalid_ollama_native_payload" }); + }); + + test("a native error record is sanitized and terminates without done", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const budget = createTestTranslatorBudget(); + // "Bearer " is a recognized secret shape for redaction (8+ chars) while + // staying below the privacy scanner's 24-char bearer rule, so the fixture stays scannable. + const frames = [{ error: { message: "boom for Bearer abcdef12345678" } }]; + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(ndjsonResponse(frames), budget)) events.push(event); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", errorType: "upstream_error" }); + expect(JSON.stringify(events[0])).not.toContain("abcdef12345678"); + expect(JSON.stringify(events[0])).toContain("[REDACTED]"); + }); + + test("stream that ends without done:true is an error, not a done", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const budget = createTestTranslatorBudget(); + const frames = [{ model: "m", message: { role: "assistant", content: "partial" }, done: false }]; + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(ndjsonResponse(frames), budget)) events.push(event); + expect(events.at(-1)?.type).toBe("error"); + expect(events.some(e => e.type === "done")).toBe(false); + }); +}); + +describe("ollama-native — buffered terminal contract", () => { + test("buffered done:true produces content + done with usage", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const events = await adapter.parseResponse!( + ndjsonResponse([{ + model: "m", + message: { role: "assistant", content: "answer" }, + done: true, + done_reason: "stop", + prompt_eval_count: 3, + eval_count: 4, + }]), + createTestTranslatorBudget(), + ); + expect(events.map(e => e.type)).toEqual(["text_delta", "done"]); + expect(events.at(-1)).toMatchObject({ type: "done", stopReason: "stop" }); + }); + + test("buffered done:false is incomplete: never a downstream done", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const events = await adapter.parseResponse!( + ndjsonResponse([{ model: "m", message: { role: "assistant", content: "half" }, done: false }]), + createTestTranslatorBudget(), + ); + expect(events.at(-1)?.type).toBe("error"); + expect((events.at(-1) as { message?: string }).message).toContain("done:false"); + expect(events.some(e => e.type === "done")).toBe(false); + // The complete payload is already known invalid, so its partial text is suppressed too. + expect(events.some(e => e.type === "text_delta")).toBe(false); + expect(events).toHaveLength(1); + }); + + test("buffered tool calls are suppressed unless done:true", async () => { + const call = { + model: "m", + message: { + role: "assistant", + content: "", + tool_calls: [{ index: 0, type: "function", id: "c0", function: { name: "ns_x__f", arguments: { p: 1 } } }], + }, + }; + const adapter = createOllamaNativeAdapter(provider()); + // done:true -> the tool call is emitted normally. + const ok = await adapter.parseResponse!( + ndjsonResponse([{ ...call, done: true, done_reason: "stop" }]), createTestTranslatorBudget()); + expect(ok.filter(e => e.type === "tool_call_start")).toHaveLength(1); + expect(ok.at(-1)?.type).toBe("done"); + + // done:false / missing / malformed -> error only: no tool_call events may execute. + for (const envelope of [{ done: false }, {}, { done: "yes" }]) { + const events = await adapter.parseResponse!( + ndjsonResponse([{ ...call, ...envelope }]), createTestTranslatorBudget()); + expect(events.filter(e => e.type === "tool_call_start"), JSON.stringify(envelope)).toHaveLength(0); + expect(events).toHaveLength(1); + expect(events[0].type).toBe("error"); + } + }); + + test("buffered missing done is incomplete", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const events = await adapter.parseResponse!( + ndjsonResponse([{ model: "m", message: { role: "assistant", content: "half" } }]), + createTestTranslatorBudget(), + ); + expect(events.at(-1)?.type).toBe("error"); + expect((events.at(-1) as { message?: string }).message).toContain("did not include done:true"); + }); + + test("buffered malformed done is malformed", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const events = await adapter.parseResponse!( + ndjsonResponse([{ model: "m", message: { role: "assistant", content: "x" }, done: "yes" }]), + createTestTranslatorBudget(), + ); + expect(events.at(-1)?.type).toBe("error"); + expect((events.at(-1) as { message?: string }).message).toContain("done flag was not boolean"); + }); + + test("buffered invalid JSON and non-object payloads fail closed", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const budget = createTestTranslatorBudget(); + const badJson = await adapter.parseResponse!(new Response("[1,2"), budget); + expect(badJson[0].type).toBe("error"); + expect((badJson[0] as { message?: string }).message).toContain("not valid JSON"); + const notObject = await adapter.parseResponse!(ndjsonResponse([[1, 2]]), budget); + expect(notObject[0]).toMatchObject({ type: "error", code: "invalid_ollama_native_payload" }); + }); +}); + +describe("ollama-native — tool calls", () => { + test("indexed tool calls preserve order and identity", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const budget = createTestTranslatorBudget(); + const frames = [ + { + model: "m", + message: { + role: "assistant", + content: "", + tool_calls: [ + { index: 0, type: "function", id: "c0", function: { name: "ns_one__alpha", arguments: { a: 1 } } }, + { index: 1, type: "function", id: "c1", function: { name: "ns_two__beta", arguments: { b: 2 } } }, + ], + }, + done: true, + done_reason: "stop", + }, + ]; + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(ndjsonResponse(frames), budget)) events.push(event); + const starts = events.filter(e => e.type === "tool_call_start") as Array<{ type: "tool_call_start"; id: string; name: string }>; + expect(starts.map(s => s.id)).toEqual(["c0", "c1"]); + expect(starts.map(s => s.name)).toEqual(["ns_one__alpha", "ns_two__beta"]); + expect(events.at(-1)?.type).toBe("done"); + }); + + test("parallelToolCalls:false rejects a second provider tool call", async () => { + const frames = [ + { + model: "m", + message: { + role: "assistant", + content: "", + tool_calls: [ + { index: 0, type: "function", function: { name: "ns_a__t", arguments: {} } }, + { index: 1, type: "function", function: { name: "ns_b__u", arguments: {} } }, + ], + }, + done: true, + done_reason: "stop", + }, + ]; + // buildRequest latches the request-level parallel flag into the adapter closure; parseStream + // then enforces it against the wire. Same instance, sequential calls — exactly the runtime path. + const strict = createOllamaNativeAdapter(provider()); + strict.buildRequest(parsedWith([{ role: "user", content: "go" }], { parallelToolCalls: false })); + const events: AdapterEvent[] = []; + for await (const event of strict.parseStream!(ndjsonResponse(frames), createTestTranslatorBudget())) { + events.push(event); + } + expect(events.some(e => e.type === "tool_call_start")).toBe(false); + expect(events.at(-1)?.type).toBe("error"); + expect((events.at(-1) as { message?: string }).message).toContain("parallel tool calls"); + + // The default (parallel allowed) still forwards both calls. + const permissive = createOllamaNativeAdapter(provider()); + permissive.buildRequest(parsedWith([{ role: "user", content: "go" }])); + const both: AdapterEvent[] = []; + for await (const event of permissive.parseStream!(ndjsonResponse(frames), createTestTranslatorBudget())) { + both.push(event); + } + expect(both.filter(e => e.type === "tool_call_start")).toHaveLength(2); + }); + + test("tool-result replay pairs a toolResult message with its call id", () => { + const adapter = createOllamaNativeAdapter(provider()); + const built = adapter.buildRequest(parsedWith([ + { role: "user", content: "run it" }, + { + role: "assistant", + timestamp: 1, + content: [{ type: "toolCall", id: "c0", name: "f", namespace: "ns", arguments: { p: 1 } }], + }, + { + role: "toolResult", + toolCallId: "c0", + toolName: "f", + toolNamespace: "ns", + content: "result-text", + isError: false, + }, + ] as never)); + const body = JSON.parse(String(built.body)); + const assistant = body.messages.at(-2); + const replayed = body.messages.at(-1); + expect(assistant.role).toBe("assistant"); + expect(assistant.tool_calls[0].id).toBe("c0"); + expect(assistant.tool_calls[0].function.name).toBe("ns__f"); + expect(replayed.role).toBe("tool"); + expect(replayed.tool_call_id).toBe("c0"); + expect(replayed.content).toContain("result-text"); + }); +}); + +describe("ollama-native — request control parity", () => { + test("presence/frequency penalties map onto native Options", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const built = await adapter.buildRequest( + parsedWith([{ role: "user", content: "hi" }], { presencePenalty: 0.25, frequencyPenalty: -0.5 }), + ); + expect(ollamaNativeChatUrl(provider().baseUrl as string)).toBe(built.url); + const options = JSON.parse(String(built.body)).options; + expect(options.presence_penalty).toBe(0.25); + expect(options.frequency_penalty).toBe(-0.5); + }); + + test("noPenaltyModels suppresses both penalties; noTemperature/noTopP suppress their own", async () => { + const gated = provider({ + noPenaltyModels: ["glm-5.3-flash"], + noTemperatureModels: ["glm-5.3-flash"], + noTopPModels: ["glm-5.3-flash"], + }); + const adapter = createOllamaNativeAdapter(gated); + const built = await adapter.buildRequest(parsedWith( + [{ role: "user", content: "hi" }], + { presencePenalty: 1, frequencyPenalty: 1, temperature: 0.7, topP: 0.9 }, + )); + const options = JSON.parse(String(built.body)).options; + expect(options).not.toHaveProperty("presence_penalty"); + expect(options).not.toHaveProperty("frequency_penalty"); + expect(options).not.toHaveProperty("temperature"); + expect(options).not.toHaveProperty("top_p"); + + // The gates are per model, not per provider: a non-listed id keeps its controls. + const otherAdapter = createOllamaNativeAdapter(provider({ + noPenaltyModels: ["some-other-model"], + })); + const ok = await otherAdapter.buildRequest( + parsedWith([{ role: "user", content: "hi" }], { presencePenalty: 0.5 }, "glm-5.3-flash"), + ); + expect(JSON.parse(String(ok.body)).options.presence_penalty).toBe(0.5); + }); + + test("num_predict, temperature, top_p and stop map as before", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const built = await adapter.buildRequest(parsedWith( + [{ role: "user", content: "hi" }], + { maxOutputTokens: 128, temperature: 0.2, topP: 0.9, stopSequences: ["END"] }, + )); + const options = JSON.parse(String(built.body)).options; + expect(options).toMatchObject({ num_predict: 128, temperature: 0.2, top_p: 0.9, stop: ["END"] }); + }); +}); + +describe("ollama-native — transport security", () => { + function headered(headers: Record, overrides: Partial = {}): OcxProviderConfig { + return provider({ headers, ...overrides }); + } + + test("remote http with an apiKey is refused", () => { + expect(() => createOllamaNativeAdapter( + provider({ baseUrl: "http://api.example.test", apiKey: "k" }), + ).buildRequest(parsedWith([{ role: "user", content: "hi" }]))).toThrow(/plaintext non-loopback HTTP/); + }); + + test("remote http with an Authorization header is refused even without an apiKey", () => { + expect(() => createOllamaNativeAdapter( + headered({ Authorization: "Bearer x" }, { baseUrl: "http://api.example.test", apiKey: undefined }), + ).buildRequest(parsedWith([{ role: "user", content: "hi" }]))).toThrow(/credential headers: Authorization/); + }); + + test("remote http with x-api-key and api-key headers is refused", () => { + for (const name of ["x-api-key", "api-key"]) { + expect(() => createOllamaNativeAdapter( + headered({ [name]: "v" }, { baseUrl: "http://api.example.test", apiKey: undefined }), + ).buildRequest(parsedWith([{ role: "user", content: "hi" }]))).toThrow(/plaintext non-loopback HTTP/); + } + }); + + test("loopback targets never receive credential headers, even from a copied provider row", () => { + const adapter = createOllamaNativeAdapter(headered( + { Authorization: "Bearer x", "x-api-key": "v", "api-key": "v2", "X-Custom": "keep" }, + { baseUrl: "http://127.0.0.1:11434", apiKey: "local-should-not-leak" }, + )); + const built = adapter.buildRequest(parsedWith([{ role: "user", content: "hi" }])); + const headers = built.headers as Record; + expect(headers.Authorization).toBeUndefined(); + expect(headers["x-api-key"]).toBeUndefined(); + expect(headers["api-key"]).toBeUndefined(); + expect(headers["X-Custom"]).toBe("keep"); + expect(JSON.stringify(headers)).not.toContain("Bearer"); + }); + + test("https header precedence matches openAIChatTransport: configured Authorization wins", () => { + // apiKey only -> generated Bearer. + const keyOnly = createOllamaNativeAdapter(provider({ baseUrl: "https://api.example.test", apiKey: "k" })); + expect((keyOnly.buildRequest(parsedWith([{ role: "user", content: "hi" }])).headers as Record).Authorization) + .toBe("Bearer k"); + + // apiKey + configured Authorization -> the CONFIGURED header wins (openai-chat applies + // provider.headers after the generated Bearer; V2 had this reversed). + const both = createOllamaNativeAdapter( + headered({ Authorization: "Bearer configured" }, { baseUrl: "https://api.example.test", apiKey: "k" }), + ); + expect((both.buildRequest(parsedWith([{ role: "user", content: "hi" }])).headers as Record).Authorization) + .toBe("Bearer configured"); + + // apiKey + configured LOWERCASE authorization -> exactly one effective authorization header, + // with the configured value. + const lower = createOllamaNativeAdapter( + headered({ authorization: "Bearer configured-lower" }, { baseUrl: "https://api.example.test", apiKey: "k" }), + ); + const lowerHeaders = lower.buildRequest(parsedWith([{ role: "user", content: "hi" }])).headers as Record; + const authKeys = Object.keys(lowerHeaders).filter(name => name.toLowerCase() === "authorization"); + expect(authKeys).toEqual(["authorization"]); + expect(lowerHeaders.authorization).toBe("Bearer configured-lower"); + + // Header-only auth with keyOptional stays supported on a secure channel. + const headerOnly = createOllamaNativeAdapter( + headered({ Authorization: "Bearer configured" }, { baseUrl: "https://api.example.test", apiKey: undefined, keyOptional: true }), + ); + expect((headerOnly.buildRequest(parsedWith([{ role: "user", content: "hi" }])).headers as Record).Authorization) + .toBe("Bearer configured"); + }); +}); diff --git a/tests/ollama-native-reasoning-wire.test.ts b/tests/ollama-native-reasoning-wire.test.ts new file mode 100644 index 0000000000..fc84e37cf3 --- /dev/null +++ b/tests/ollama-native-reasoning-wire.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "bun:test"; +import { createOllamaNativeAdapter } from "../src/adapters/ollama-native"; +import { buildCatalogEntries, gatherRoutedModels as gatherRoutedModelsDirect } from "../src/codex/catalog"; +import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; +import { REASONING_EFFORT_OMIT_SENTINEL } from "../src/reasoning-effort"; +import type { OcxParsedRequest, OcxProviderConfig } from "../src/types"; + +const gatherRoutedModels: typeof gatherRoutedModelsDirect = (config, options) => + gatherRoutedModelsDirect(withStubbedProviderFetch(config), options); + +/** + * The wire-level reasoning invariant. + * + * Upstream DELIBERATELY advertises synthetic max/ultra rungs on reasoning-capable routed rows: + * Codex and subagent spawns validate requested efforts against catalog membership, so a missing + * top rung hard-fails spawn_agent effort overrides. The wire stays honest because the native + * adapter clamps the requested effort onto the provider's real supported ladder. These tests pin + * the WIRE behavior (what actually reaches /api/chat), not the catalog shape. + */ +function provider(modelReasoningEfforts: Record): OcxProviderConfig { + return { + adapter: "ollama-native", + baseUrl: "https://ollama.com/v1", + authMode: "key", + apiKey: "test-key-not-a-real-credential", + liveModels: false, + models: ["deepseek-v4-flash:0731"], + modelReasoningEfforts: modelReasoningEfforts, + } as OcxProviderConfig; +} + +function parsedWith(options: Record, modelId = "deepseek-v4-flash:0731"): OcxParsedRequest { + return { modelId, stream: true, options, context: { messages: [{ role: "user", content: "hi" }] } } as unknown as OcxParsedRequest; +} + +describe("ollama-native — reasoning wire clamp (catalog universality preserved)", () => { + test("RED/GREEN: synthetic catalog rungs do NOT leak unsupported think values onto the wire", async () => { + // Real provider ladder is only [low, medium, high]. The catalog advertises the synthetic + // max/ultra rungs (upstream requirement); a max or ultra request must serialize the CLAMPED + // supported value, never an unsupported one. + const adapter = createOllamaNativeAdapter(provider({ "deepseek-v4-flash:0731": ["low", "medium", "high"] })); + for (const requested of ["max", "ultra"]) { + const { body } = await adapter.buildRequest(parsedWith({ reasoning: requested })); + const think = JSON.parse(String(body)).think; + expect(think, `requested=${requested}`).toBe("high"); + } + // In-ladder values pass through unchanged. + for (const [requested, expected] of [["low", "low"], ["medium", "medium"], ["high", "high"]] as const) { + const { body } = await adapter.buildRequest(parsedWith({ reasoning: requested })); + expect(JSON.parse(String(body)).think).toBe(expected); + } + // And the catalog really does advertise the synthetic rungs this clamp exists for. + const models = await gatherRoutedModels({ providers: { "ollama-cloud": provider({ "deepseek-v4-flash:0731": ["low", "medium", "high"] }) } } as never); + const entries = buildCatalogEntries(null, [], models); + const levels = ((entries.find(e => e.slug === "ollama-cloud/deepseek-v4-flash:0731")?.supported_reasoning_levels ?? []) as Array<{ effort?: string }>).map(l => l.effort); + expect(levels).toEqual(["low", "medium", "high", "max", "ultra"]); + }); + + test("an explicit __omit__ mapping leaves the reasoning field OFF the wire", async () => { + const adapter = createOllamaNativeAdapter(provider({ + "deepseek-v4-flash:0731": ["low", "medium", "high"], + })); + // Drive the omit sentinel through a provider reasoning map: max -> __omit__. + const omitting = createOllamaNativeAdapter({ + ...provider({ "deepseek-v4-flash:0731": ["low", "medium", "high"] }), + modelReasoningEffortMap: { "deepseek-v4-flash:0731": { max: REASONING_EFFORT_OMIT_SENTINEL } }, + } as never); + const { body } = await omitting.buildRequest(parsedWith({ reasoning: "max" })); + const parsed = JSON.parse(String(body)); + expect(parsed).not.toHaveProperty("think"); + void adapter; + }); + + test("a low-effort request on an omit-mapped model stays omitted", async () => { + const omitting = createOllamaNativeAdapter({ + ...provider({ "deepseek-v4-flash:0731": ["low", "medium", "high"] }), + modelReasoningEffortMap: { "deepseek-v4-flash:0731": { low: REASONING_EFFORT_OMIT_SENTINEL } }, + } as never); + const { body } = await omitting.buildRequest(parsedWith({ reasoning: "low" })); + expect(JSON.parse(String(body))).not.toHaveProperty("think"); + }); +}); + +describe("ollama — post-clamp __omit__ sentinel (V9)", () => { + test("RED (V8 semantics) / GREEN: wireMap.high=__omit__ + requested max omits the field, never think:max", async () => { + const adapter = createOllamaNativeAdapter({ + ...provider({ "deepseek-v4-flash:0731": ["low", "medium", "high"] }), + modelReasoningEffortMap: { "deepseek-v4-flash:0731": { high: REASONING_EFFORT_OMIT_SENTINEL } }, + } as never); + const { body } = await adapter.buildRequest(parsedWith({ reasoning: "max" })); + // mapReasoningEffort clamps max -> high; the wire rung's __omit__ mapping is authoritative. + expect(JSON.parse(String(body))).not.toHaveProperty("think"); + }); + + test("GREEN: ultra with max->high clamp still serializes high (boundary-first preserved)", async () => { + const adapter = createOllamaNativeAdapter({ + ...provider({ "deepseek-v4-flash:0731": ["low", "medium", "high"] }), + modelReasoningEffortMap: { "deepseek-v4-flash:0731": { ultra: REASONING_EFFORT_OMIT_SENTINEL, max: "high" } }, + } as never); + const { body } = await adapter.buildRequest(parsedWith({ reasoning: "ultra" })); + expect(JSON.parse(String(body)).think).toBe("high"); + }); + + test("GREEN: none -> __omit__ omits; none without a mapping still serializes think:false", async () => { + const omitting = createOllamaNativeAdapter({ + ...provider({ "deepseek-v4-flash:0731": ["low", "medium", "high"] }), + modelReasoningEffortMap: { "deepseek-v4-flash:0731": { none: REASONING_EFFORT_OMIT_SENTINEL } }, + } as never); + const { body } = await omitting.buildRequest(parsedWith({ reasoning: "none" })); + expect(JSON.parse(String(body))).not.toHaveProperty("think"); + + const plain = createOllamaNativeAdapter(provider({ "deepseek-v4-flash:0731": ["low", "medium", "high"] })); + const plainBuilt = await plain.buildRequest(parsedWith({ reasoning: "none" })); + expect(JSON.parse(String(plainBuilt.body)).think).toBe(false); + }); + + test("GREEN: the clamp itself is unchanged (max/ultra -> high with no omit mapping)", async () => { + const adapter = createOllamaNativeAdapter(provider({ "deepseek-v4-flash:0731": ["low", "medium", "high"] })); + for (const requested of ["max", "ultra"]) { + const { body } = await adapter.buildRequest(parsedWith({ reasoning: requested })); + expect(JSON.parse(String(body)).think).toBe("high"); + } + }); +}); diff --git a/tests/ollama-native-structured-output.test.ts b/tests/ollama-native-structured-output.test.ts new file mode 100644 index 0000000000..4e2bcfa001 --- /dev/null +++ b/tests/ollama-native-structured-output.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from "bun:test"; +import { createOllamaNativeAdapter } from "../src/adapters/ollama-native"; +import type { OcxParsedRequest, OcxProviderConfig } from "../src/types"; + +/** + * Structured output is a capability boundary, not a formatting preference. + * + * Ollama documents that "Ollama's Cloud currently does not support structured outputs" + * (ollama/ollama docs/capabilities/structured-outputs.mdx). Cloud does not reject the `format` + * field — it answers 200 and ignores it — so forwarding the field would hand the caller + * unconstrained prose while its request said the answer would be schema-valid. The adapter + * refuses the contract instead, the same call Kiro makes for a wire that cannot enforce it. + * + * Local and custom self-hosted Ollama honour `format`, so they keep mapping it. + */ + +function provider(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "ollama-native", + baseUrl: "https://ollama.com/v1", + authMode: "key", + apiKey: "test-key-not-a-real-credential", + liveModels: false, + models: ["glm-5.3-flash"], + ...overrides, + } as OcxProviderConfig; +} + +const LOCAL = { baseUrl: "http://localhost:11434/v1", authMode: "local", apiKey: undefined } as Partial; +const CUSTOM = { baseUrl: "https://ollama.internal.example/api", authMode: "key", apiKey: "test-key-not-a-real-credential" } as Partial; + +const SCHEMA = { + type: "object", + properties: { ok: { type: "boolean" } }, + required: ["ok"], +} as Record; + +function parsedWith(options: Record = {}, modelId = "glm-5.3-flash"): OcxParsedRequest { + return { + modelId, + stream: true, + options, + context: { messages: [{ role: "user", content: "hi" }] }, + } as unknown as OcxParsedRequest; +} + +const JSON_OBJECT = { textFormat: { type: "json_object" } }; +const JSON_SCHEMA = { textFormat: { type: "json_schema", name: "answer", schema: SCHEMA } }; + +describe("ollama-native — structured output is refused on canonical Ollama Cloud", () => { + test("canonical Cloud + json_object fails closed", () => { + expect(() => createOllamaNativeAdapter(provider()).buildRequest(parsedWith(JSON_OBJECT))) + .toThrow("ollama-native does not support structured output on Ollama Cloud"); + }); + + test("canonical Cloud + json_schema fails closed", () => { + expect(() => createOllamaNativeAdapter(provider()).buildRequest(parsedWith(JSON_SCHEMA))) + .toThrow("ollama-native does not support structured output on Ollama Cloud"); + }); + + test("every accepted canonical Cloud base-URL spelling refuses it, not just the stored /v1 form", () => { + for (const baseUrl of ["https://ollama.com", "https://ollama.com/v1", "https://ollama.com/api", "https://ollama.com/api/chat", "https://ollama.com./api"]) { + expect(() => createOllamaNativeAdapter(provider({ baseUrl })).buildRequest(parsedWith(JSON_SCHEMA)), baseUrl) + .toThrow("ollama-native does not support structured output on Ollama Cloud"); + } + }); + + test("www Ollama spelling cannot bypass the Cloud structured-output boundary", () => { + expect(() => createOllamaNativeAdapter(provider({ baseUrl: "https://www.ollama.com/v1" })) + .buildRequest(parsedWith(JSON_SCHEMA))) + .toThrow("requires canonical Ollama Cloud host ollama.com"); + }); + + test("CONTROL: ordinary Cloud prose is completely unaffected", () => { + const request = createOllamaNativeAdapter(provider()).buildRequest(parsedWith({})); + const body = JSON.parse(request.body as string) as Record; + expect(request.url).toBe("https://ollama.com/api/chat"); + expect(body).not.toHaveProperty("format"); + expect(body.model).toBe("glm-5.3-flash"); + expect(Array.isArray(body.messages)).toBe(true); + }); + + test("CONTROL: unrelated parsed request options do not trip the structured-output guard", () => { + // `textFormat` remains unset; unrelated parsed request options do not mean structured output. + const request = createOllamaNativeAdapter(provider()).buildRequest(parsedWith({ temperature: 0 })); + const body = JSON.parse(request.body as string) as Record; + expect(body).not.toHaveProperty("format"); + expect((body.options as Record).temperature).toBe(0); + }); +}); + +describe("ollama-native — local and custom endpoints keep native structured output", () => { + test("local Ollama serializes json_object as format:\"json\"", () => { + const request = createOllamaNativeAdapter(provider(LOCAL)).buildRequest(parsedWith(JSON_OBJECT)); + const body = JSON.parse(request.body as string) as Record; + expect(request.url).toBe("http://localhost:11434/api/chat"); + expect(body.format).toBe("json"); + }); + + test("local Ollama serializes a json_schema as the schema object itself", () => { + const request = createOllamaNativeAdapter(provider(LOCAL)).buildRequest(parsedWith(JSON_SCHEMA)); + const body = JSON.parse(request.body as string) as Record; + // Ollama's native contract takes the schema directly, not OpenAI's response_format wrapper. + expect(body.format).toEqual(SCHEMA); + expect(body.format).not.toHaveProperty("json_schema"); + }); + + test("custom self-hosted Ollama keeps both native format spellings", () => { + const objectBody = JSON.parse( + createOllamaNativeAdapter(provider(CUSTOM)).buildRequest(parsedWith(JSON_OBJECT)).body as string, + ) as Record; + expect(objectBody.format).toBe("json"); + + const schemaRequest = createOllamaNativeAdapter(provider(CUSTOM)).buildRequest(parsedWith(JSON_SCHEMA)); + const schemaBody = JSON.parse(schemaRequest.body as string) as Record; + expect(schemaRequest.url).toBe("https://ollama.internal.example/api/chat"); + expect(schemaBody.format).toEqual(SCHEMA); + }); + + test("a malformed json_schema still fails on its own terms off Cloud", () => { + // The Cloud guard must not swallow the pre-existing schema-shape validation. + expect(() => createOllamaNativeAdapter(provider(LOCAL)) + .buildRequest(parsedWith({ textFormat: { type: "json_schema", name: "answer" } }))) + .toThrow("ollama-native json_schema output requires a JSON schema object"); + }); +}); diff --git a/tests/ollama-native-v4.test.ts b/tests/ollama-native-v4.test.ts new file mode 100644 index 0000000000..4da76379d0 --- /dev/null +++ b/tests/ollama-native-v4.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from "bun:test"; +import { createOllamaNativeAdapter } from "../src/adapters/ollama-native"; +import { createTestTranslatorBudget } from "./helpers/translator-budget"; +import { REASONING_EFFORT_OMIT_SENTINEL } from "../src/reasoning-effort"; +import type { AdapterEvent } from "../src/types"; +import type { OcxParsedRequest, OcxProviderConfig } from "../src/types"; + +function provider(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "ollama-native", + baseUrl: "https://ollama.com/v1", + authMode: "key", + apiKey: "test-key-not-a-real-credential", + liveModels: false, + models: ["glm-5.3-flash"], + ...overrides, + } as OcxProviderConfig; +} + +function parsedWith(options: Record = {}, modelId = "glm-5.3-flash"): OcxParsedRequest { + return { modelId, stream: true, options, context: { messages: [{ role: "user", content: "hi" }] } } as unknown as OcxParsedRequest; +} + +/** + * V4 corrections: EOF accounting parity and boundary-first omit semantics. + */ + +describe("ollama-native — EOF vs newline accounting parity", () => { + /** One buffered terminal record: `textSize` content + one tool call with `argSize` of arguments. */ + function record(textSize: number, argSize: number): Record { + return { + model: "m", + message: { + role: "assistant", + content: "r".repeat(textSize), + tool_calls: [{ + index: 0, type: "function", id: "c0", + function: { name: "ns_x__f", arguments: { blob: "a".repeat(argSize) } }, + }], + }, + done: true, + done_reason: "stop", + prompt_eval_count: 1, + eval_count: 1, + }; + } + + async function run(rec: unknown, eof: boolean) { + const adapter = createOllamaNativeAdapter(provider()); + const budget = createTestTranslatorBudget(); + const text = JSON.stringify(rec) + (eof ? "" : "\n"); + const response = new Response(new TextEncoder().encode(text), { + headers: { "content-type": "application/x-ndjson" }, + }); + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(response, budget)) events.push(event); + return { events, snapshot: budget.snapshot() }; + } + + test("small terminal record: identical events and identical outcome for both terminators", async () => { + const rec = record(4 * 1024 * 1024, 1024 * 1024); + const nl = await run(rec, false); + const eof = await run(rec, true); + expect(eof.events.map(e => e.type)).toEqual(nl.events.map(e => e.type)); + expect(nl.events.some(e => e.type === "tool_call_start")).toBe(true); + expect(eof.events.at(-1)?.type).toBe("done"); + expect(eof.snapshot.highWaterBytes).toBe(nl.snapshot.highWaterBytes); + }); + + test("non-vacuous near-limit record: aggregate (record + parsed tool args) exceeds the 32 MiB turn cap, while the record itself and each tool argument stay under their individual limits — same budget outcome for both terminators", async () => { + // Margins: content 30 MiB + args 1.5 MiB => line ≈ 31.5 MiB (< 32 MiB record/line ceiling; + // args 1.5 MiB < the 2 MiB per-call tool-argument limit). While the record is retained, the + // parsed tool-argument copy pushes the aggregate translator charge past 32 MiB, so BOTH + // terminators must fail with translation_buffer_limit. If the EOF residual were released + // before tool translation, the args alone (1.5 MiB) would fit and the EOF case would emit + // the tool call — the asymmetry that proves the record stays charged until translated. + const rec = record(30 * 1024 * 1024, 1.5 * 1024 * 1024); + const nl = await run(rec, false); + const eof = await run(rec, true); + for (const label of ["newline", "eof"]) { + const events = label === "newline" ? nl.events : eof.events; + expect(events, label).toHaveLength(1); + expect(events[0], label).toMatchObject({ type: "error", code: "translation_buffer_limit" }); + } + expect(nl.snapshot.highWaterBytes).toBe(eof.snapshot.highWaterBytes); + }); +}); + +describe("ollama-native — omit sentinel under the ultra boundary", () => { + test("ultra→__omit__ with max→high must CLAMP, not omit (boundary-first)", async () => { + const adapter = createOllamaNativeAdapter({ + ...provider(), + models: ["glm-5.3-flash"], + modelReasoningEfforts: { "glm-5.3-flash": ["low", "medium", "high"] }, + modelReasoningEffortMap: { + "glm-5.3-flash": { ultra: REASONING_EFFORT_OMIT_SENTINEL, max: "high" }, + }, + } as never); + const { body } = await adapter.buildRequest(parsedWith({ reasoning: "ultra" })); + expect(JSON.parse(String(body)).think).toBe("high"); + }); + + test("max→__omit__ is honoured (inverse control)", async () => { + const adapter = createOllamaNativeAdapter({ + ...provider(), + modelReasoningEffortMap: { "glm-5.3-flash": { max: REASONING_EFFORT_OMIT_SENTINEL } }, + } as never); + const { body } = await adapter.buildRequest(parsedWith({ reasoning: "max" })); + expect(JSON.parse(String(body))).not.toHaveProperty("think"); + }); + + test("none→__omit__ omits: the explicit mapping outranks the native none=>false fallback", async () => { + const adapter = createOllamaNativeAdapter({ + ...provider(), + modelReasoningEffortMap: { "glm-5.3-flash": { none: REASONING_EFFORT_OMIT_SENTINEL } }, + } as never); + const { body } = await adapter.buildRequest(parsedWith({ reasoning: "none" })); + expect(JSON.parse(String(body))).not.toHaveProperty("think"); + }); + + test("none without an omit mapping still serializes think:false", async () => { + const adapter = createOllamaNativeAdapter(provider()); + const { body } = await adapter.buildRequest(parsedWith({ reasoning: "none" })); + expect(JSON.parse(String(body)).think).toBe(false); + }); +}); diff --git a/tests/ollama-native.test.ts b/tests/ollama-native.test.ts new file mode 100644 index 0000000000..65c90c5848 --- /dev/null +++ b/tests/ollama-native.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, test } from "bun:test"; +import { createOllamaNativeAdapter } from "../src/adapters/ollama-native"; +import { + ollamaNativeChatUrl, + ollamaNativeEndpointKind, +} from "../src/adapters/ollama-native-url"; +import { buildCatalogEntries, gatherRoutedModels as gatherRoutedModelsDirect, upstreamNativeEntry } from "../src/codex/catalog"; +import { getProviderRegistryEntry } from "../src/providers/registry"; +import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; +import type { OcxParsedRequest, OcxProviderConfig } from "../src/types"; + +const gatherRoutedModels: typeof gatherRoutedModelsDirect = (config, options) => + gatherRoutedModelsDirect(withStubbedProviderFetch(config), options); + +/** The four ids this transport is maintained against. */ +const TARGETS = ["glm-5.3-flash", "deepseek-v4-flash:0731", "glm-5.2", "kimi-k3"] as const; + +function ollamaProvider(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "ollama-native", + baseUrl: "https://ollama.com/v1", + authMode: "key", + apiKey: "test-key-not-a-real-credential", + liveModels: false, + models: [...TARGETS], + modelReasoningEfforts: { "deepseek-v4-flash:0731": ["low", "medium", "high", "max"] }, + ...overrides, + } as OcxProviderConfig; +} + +function parsedWith( + messages: unknown[], + options: Record = {}, + modelId = "glm-5.3-flash", +): OcxParsedRequest { + return { modelId, stream: true, options, context: { messages } } as unknown as OcxParsedRequest; +} + +describe("ollama-native — URL policy", () => { + test("normalizes every accepted cloud spelling onto /api/chat", () => { + for (const base of [ + "https://ollama.com", + "https://ollama.com/", + "https://ollama.com/v1", + "https://ollama.com/v1/chat/completions", + "https://ollama.com/api", + "https://ollama.com/api/chat", + ]) { + expect(ollamaNativeChatUrl(base)).toBe("https://ollama.com/api/chat"); + } + }); + + test("live model discovery is origin-relative, so the stored /v1 base reaches /v1/models", () => { + // model-discovery resolves a leading-slash spec path against base.origin: + // path "/v1/models" on baseUrl https://ollama.com/v1 -> https://ollama.com/v1/models. + const base = new URL("https://ollama.com/v1/"); + expect(new URL("/v1/models", base.origin).toString()).toBe("https://ollama.com/v1/models"); + }); + + test("classifies endpoints and refuses unsafe cloud transports", () => { + expect(ollamaNativeEndpointKind("https://ollama.com/v1")).toBe("cloud"); + expect(ollamaNativeEndpointKind("http://localhost:11434")).toBe("local"); + expect(ollamaNativeEndpointKind("https://ollama.internal.example/api")).toBe("custom"); + expect(() => ollamaNativeChatUrl("http://ollama.com/v1")).toThrow(/HTTPS/); + expect(() => ollamaNativeChatUrl("https://ollama.com:8443/v1")).toThrow(/non-default ports/); + }); + + test("treats one terminal-dot Ollama Cloud hostname as canonical", () => { + expect(ollamaNativeEndpointKind("https://ollama.com./api")).toBe("cloud"); + expect(ollamaNativeChatUrl("https://ollama.com./api")).toBe("https://ollama.com/api/chat"); + }); + + test("rejects the www Ollama Cloud alias instead of treating it as custom", () => { + expect(() => ollamaNativeEndpointKind("https://www.ollama.com/v1")) + .toThrow("requires canonical Ollama Cloud host ollama.com"); + expect(() => ollamaNativeChatUrl("https://www.ollama.com/v1")) + .toThrow("requires canonical Ollama Cloud host ollama.com"); + }); + + test("never silently rewrites a /v1 path on an unrelated host", () => { + expect(() => ollamaNativeChatUrl("https://ollama.internal.example/v1")).toThrow(/refuses custom baseUrl path/); + expect(ollamaNativeChatUrl("https://ollama.internal.example/api")).toBe("https://ollama.internal.example/api/chat"); + }); + + test("rejects credential-bearing, query-bearing and non-http base URLs", () => { + expect(() => ollamaNativeChatUrl("https://user:pw@chatgpt.com/v1")).toThrow(/must not contain credentials/); + expect(() => ollamaNativeChatUrl("https://ollama.com/v1?k=v")).toThrow(/must not contain credentials/); + expect(() => ollamaNativeChatUrl("ftp://ollama.com")).toThrow(/only supports http/); + expect(() => ollamaNativeChatUrl(" ")).toThrow(/non-empty baseUrl/); + }); +}); + +describe("ollama-native — registry and discovery contract", () => { + test("the registry declares the native transport and origin-relative /v1/models discovery", () => { + const entry = getProviderRegistryEntry("ollama-cloud"); + expect(entry?.adapter).toBe("ollama-native"); + // The compat base URL is deliberately retained; the normalizer maps it to /api/chat. + expect(entry?.baseUrl).toBe("https://ollama.com/v1"); + // Discovery resolves the leading-slash path against the ORIGIN, giving + // https://ollama.com/v1/models — the standard data[] envelope the generic pipeline + // already understands, so no special-case envelope code ships with this adapter. + expect(entry?.modelDiscovery).toEqual({ path: "/v1/models" }); + expect(entry?.modelContextWindows).toMatchObject({ + "glm-5.3": 1_048_576, + "glm-5.3-flash": 1_048_576, + }); + }); +}); + +describe("ollama-native — truthful serialized catalog capabilities", () => { + test("no target advertises verbosity, a verbosity default, or a service/speed tier", async () => { + const models = await gatherRoutedModels({ providers: { "ollama-cloud": ollamaProvider() } } as never); + const entries = buildCatalogEntries(null, [], models); + + for (const id of TARGETS) { + const entry = entries.find(e => e.slug === `ollama-cloud/${id}`); + expect(entry).toBeDefined(); + // Serialized Codex spelling. `supports_verbosity` (plural) does not exist in this format, + // which is exactly how an earlier assertion passed while the rows advertised the control. + expect(entry).not.toHaveProperty("supports_verbosity"); + expect(entry?.support_verbosity).toBe(false); + // default_verbosity is owned by the generic catalog verbosity fix (#2799); on a dev tree + // without it the strict-fields backfill still emits "low" here. Not asserted in this PR. + expect(entry?.service_tiers).toBeUndefined(); + expect(entry?.default_service_tier).toBeUndefined(); + expect(entry?.additional_speed_tiers).toBeUndefined(); + expect(entry?.fast_tier_description).toBeUndefined(); + } + }); + + test("a live-discovered Ollama id inherits the provider-wide opt-out", async () => { + // The Ollama catalog is discovery-authoritative, so ids absent from the registry row still + // reach the catalog. A per-model map alone would let those re-advertise the control. + const models = await gatherRoutedModels({ + providers: { "ollama-cloud": ollamaProvider({ models: ["a-model-not-in-the-registry"] }) }, + } as never); + const entries = buildCatalogEntries(null, [], models); + const entry = entries.find(e => e.slug === "ollama-cloud/a-model-not-in-the-registry"); + expect(entry?.support_verbosity).toBe(false); + }); + + test("CONTROL: a routed provider that never disowns verbosity keeps the permissive default", async () => { + const models = await gatherRoutedModels({ + providers: { + plain: { + adapter: "openai-responses", + baseUrl: "https://plain.example.test/v1", + authMode: "key", + liveModels: false, + models: ["plain-model"], + }, + }, + } as never); + const entries = buildCatalogEntries(null, [], models); + const entry = entries.find(e => e.slug === "plain/plain-model"); + expect(entry?.support_verbosity).toBe(true); + }); + + test("CONTROL: xAI's own opt-out is unchanged", async () => { + const models = await gatherRoutedModels({ + providers: { + xai: { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "oauth", + liveModels: false, + models: ["grok-4.6"], + }, + }, + } as never); + const entries = buildCatalogEntries(null, [], models); + const entry = entries.find(e => e.slug === "xai/grok-4.6"); + expect(entry?.support_verbosity).toBe(false); + }); + + test("CONTROL: a native OpenAI row keeps verbosity, which it genuinely supports", () => { + const template = upstreamNativeEntry("gpt-5.6-sol"); + expect(template).not.toBeNull(); + const entries = buildCatalogEntries(template, ["gpt-5.6-sol"], []); + const native = entries.find(e => e.slug === "gpt-5.6-sol"); + expect(native).toBeDefined(); + expect(native?.support_verbosity).toBe(true); + }); +}); + +describe("ollama-native — reasoning ladder", () => { + test("routed rows carry the upstream-required synthetic top rungs; the WIRE clamps", async () => { + const models = await gatherRoutedModels({ providers: { "ollama-cloud": ollamaProvider() } } as never); + const entries = buildCatalogEntries(null, [], models); + const efforts = (slug: string) => + ((entries.find(e => e.slug === slug)?.supported_reasoning_levels ?? []) as Array<{ effort?: string }>) + .map(l => l.effort); + + // Catalog universality (upstream design): every reasoning-capable routed row advertises the + // synthetic top rungs so subagent effort overrides validate by catalog membership. The + // ollama-native adapter is responsible for keeping the WIRE honest (see the wire-clamp tests). + expect(efforts("ollama-cloud/deepseek-v4-flash:0731")).toEqual(["low", "medium", "high", "max", "ultra"]); + for (const id of ["glm-5.3-flash", "glm-5.2", "kimi-k3"]) { + expect(efforts(`ollama-cloud/${id}`)).toContain("max"); + expect(efforts(`ollama-cloud/${id}`)).toContain("ultra"); + } + }); + + test("every advertised rung maps into an allowed native wire value", async () => { + const provider = ollamaProvider(); + const adapter = createOllamaNativeAdapter(provider); + const allowed = new Set([undefined, true, false, "low", "medium", "high", "max"]); + for (const requested of ["minimal", "low", "medium", "high", "xhigh", "max", "ultra", "none"]) { + const { body } = await adapter.buildRequest(parsedWith([{ role: "user", content: "hi" }], { reasoning: requested })); + const think = JSON.parse(String(body)).think; + expect(allowed.has(think)).toBe(true); + } + // xhigh and ultra collapse onto Ollama's top rung rather than being sent through verbatim. + for (const requested of ["xhigh", "ultra"]) { + const { body } = await adapter.buildRequest(parsedWith([{ role: "user", content: "hi" }], { reasoning: requested })); + expect(JSON.parse(String(body)).think).toBe("max"); + } + }); + + test("an unmappable effort fails the turn instead of degrading silently", () => { + const adapter = createOllamaNativeAdapter(ollamaProvider()); + expect(() => + adapter.buildRequest(parsedWith([{ role: "user", content: "hi" }], { reasoning: "turbo" })), + ).toThrow(/does not support reasoning level/); + }); +}); + +describe("ollama-native — request shape", () => { + test("posts to the native chat endpoint with the wire model id", async () => { + const adapter = createOllamaNativeAdapter(ollamaProvider()); + const { url, method, body } = await adapter.buildRequest(parsedWith([{ role: "user", content: "hi" }])); + expect(url).toBe("https://ollama.com/api/chat"); + expect(method).toBe("POST"); + expect(JSON.parse(String(body)).model).toBe("glm-5.3-flash"); + }); + + test("a caller-supplied verbosity never reaches /api/chat", async () => { + const adapter = createOllamaNativeAdapter(ollamaProvider()); + const { body } = await adapter.buildRequest( + parsedWith([{ role: "user", content: "hi" }], { verbosity: "high", text: { verbosity: "high" } }), + ); + const serialized = String(body); + expect(serialized).not.toContain("verbosity"); + const parsed = JSON.parse(serialized); + expect(parsed).not.toHaveProperty("verbosity"); + expect(parsed.options ?? {}).not.toHaveProperty("verbosity"); + }); + + test("images travel in the native images[] array, and video is refused", async () => { + const adapter = createOllamaNativeAdapter(ollamaProvider()); + const png = "data:image/png;base64,iVBORw0KGgo="; + const { body } = await adapter.buildRequest(parsedWith([ + { role: "user", content: [{ type: "text", text: "read it" }, { type: "image", imageUrl: png }] }, + ])); + const message = JSON.parse(String(body)).messages.at(-1); + expect(Array.isArray(message.images)).toBe(true); + expect(message.images[0]).toBe("iVBORw0KGgo="); + expect(message.content).toContain("read it"); + + expect(() => adapter.buildRequest(parsedWith([ + { role: "user", content: [{ type: "video", videoUrl: "data:video/mp4;base64,AAAA" }] }, + ]))).toThrow(/cannot send video/); + }); +}); diff --git a/tests/ollama-show-enrichment-v7.test.ts b/tests/ollama-show-enrichment-v7.test.ts new file mode 100644 index 0000000000..a82f8a336d --- /dev/null +++ b/tests/ollama-show-enrichment-v7.test.ts @@ -0,0 +1,502 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { gatherRoutedModels, resetCatalogRuntimeStateForTests, resetOpenAiApiCatalogWarningStateForTests } from "../src/codex/catalog"; +import { clearModelCache } from "../src/codex/model-cache"; + +afterEach(() => { + // The provider-model cache is keyed by provider name; without this, one test's gather would + // satisfy the next test's discovery from the previous test's cached rows. + globalThis.fetch = originalFetch; + clearModelCache(); + resetOpenAiApiCatalogWarningStateForTests(); + resetCatalogRuntimeStateForTests(); +}); + +const originalFetch = globalThis.fetch; +import { fetchOllamaShowEnrichment, ollamaShowMetadataFromPayload, showHeadersFromCaptured } from "../src/providers/ollama-show"; +import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; +import type { OcxConfig } from "../src/types"; + +/** + * V7: auth/outbound-policy integration, aggregate fan-out bounds, and models-API precedence. + * The show request must reuse the discovery request's already-materialized captured headers and + * execute through the same outbound-policy transport as discovery — never manufacturing its own + * auth contract from apiKey, never using a raw fetch. + */ + +function jsonRes(payload: unknown, status = 200): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function ollamaShow(contextLength: number, capabilities: string[]): Response { + return jsonRes({ + model_info: { + "general.architecture": "testarch", + "testarch.context_length": contextLength, + }, + capabilities, + }); +} + +interface Call { url: string; body: string; init: RequestInit; auth: string | undefined } + +function stubFetch( + handler: (url: string, body: string) => Response, +): { calls: Array; uninstall: () => void } { + const calls: Array = []; + const original = globalThis.fetch; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + const headers = (init?.headers ?? {}) as Record; + calls.push({ + url, + body: typeof init?.body === "string" ? init.body : "", + init: init ?? {}, + auth: headers.Authorization ?? headers.authorization, + }); + return Promise.resolve(handler(url, calls.at(-1)!.body)); + }) as typeof fetch; + return { calls, uninstall: () => { globalThis.fetch = original; } }; +} + +const showCalls = (calls: Array) => calls.filter(c => c.url.endsWith("/api/show")); + +function providerConfig(headers?: Record): OcxConfig { + return { + port: 10114, + defaultProvider: "ollama-cloud", + providers: { + "ollama-cloud": { + adapter: "ollama-native", + baseUrl: "https://ollama.com/v1", + authMode: "key", + apiKey: "test-key-not-a-real-credential", + liveModels: true, + models: [], + ...(headers ? { headers } : {}), + }, + }, + } as never as OcxConfig; +} + +describe("ollama /api/show — auth and outbound-policy integration", () => { + test("1: apiKey-generated auth follows the captured provider request", async () => { + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: [{ id: "glm-5.3" }] }); + if (url.endsWith("/api/show")) return ollamaShow(1_048_576, ["completion"]); + return new Response("nf", { status: 404 }); + }); + try { + await gatherRoutedModels(withStubbedProviderFetch(providerConfig())); + const show = showCalls(stub.calls); + expect(show).toHaveLength(1); + // The generated Bearer value is materialized from the provider credential; assert + // presence and shape without embedding a scanner-flagged bearer literal. + expect((show[0].auth ?? "").startsWith("Bearer ")).toBe(true); + expect(show[0].init.method).toBe("POST"); + } finally { + stub.uninstall(); + } + }); + + test("2: the show request's auth deterministically matches the captured discovery request (configured headers included)", async () => { + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: [{ id: "glm-5.3" }] }); + if (url.endsWith("/api/show")) return ollamaShow(1_048_576, ["completion"]); + return new Response("nf", { status: 404 }); + }); + try { + await gatherRoutedModels(withStubbedProviderFetch( + providerConfig({ Authorization: "Bearer configured-value" }), + )); + const show = showCalls(stub.calls); + expect(show).toHaveLength(1); + // The captured discovery headers ARE the authority: the show request carries exactly the + // auth the /v1/models request carried (buildModelsRequest materialization governs both — + // discovery's generic tail writes a canonical-case Authorization after merging configured + // headers, so generated Bearer wins there; the show request introduces no separate + // contract and mirrors the result verbatim). + const modelsCall = stub.calls.find(c => c.url.endsWith("/v1/models")); + expect(show[0].auth).toBe(modelsCall?.auth); + } finally { + stub.uninstall(); + } + }); + + test("3: lowercase authorization is mirrored verbatim — the show request adds no spelling of its own", async () => { + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: [{ id: "glm-5.3" }] }); + if (url.endsWith("/api/show")) return ollamaShow(1_048_576, ["completion"]); + return new Response("nf", { status: 404 }); + }); + try { + await gatherRoutedModels(withStubbedProviderFetch( + providerConfig({ authorization: "Bearer configured-lower" }), + )); + const show = showCalls(stub.calls); + expect(show).toHaveLength(1); + // Parity: the show headers contain exactly the authorization materialization the + // discovery request had — no extra credential spelling introduced by the show path. + const showInit = (show[0].init.headers ?? {}) as Record; + const modelsCall = stub.calls.find(c => c.url.endsWith("/v1/models")); + const modelsInit = (modelsCall?.init.headers ?? {}) as Record; + const showAuth = Object.entries(showInit).filter(([n]) => n.toLowerCase() === "authorization"); + const modelsAuth = Object.entries(modelsInit).filter(([n]) => n.toLowerCase() === "authorization"); + expect(showAuth).toEqual(modelsAuth); + expect(showAuth.length).toBeGreaterThan(0); + } finally { + stub.uninstall(); + } + }); + + test("4: the provider.fetch executor is invoked through the outbound-policy wrapper", async () => { + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: [{ id: "glm-5.3" }] }); + if (url.endsWith("/api/show")) return ollamaShow(1_048_576, ["completion"]); + return new Response("nf", { status: 404 }); + }); + try { + await gatherRoutedModels(withStubbedProviderFetch(providerConfig())); + const show = showCalls(stub.calls); + expect(show).toHaveLength(1); + // providerOutboundPost forwards method + redirect:"manual" to the executor; a raw + // globalThis.fetch call from the enrichment would not carry these. + expect(show[0].init.method).toBe("POST"); + expect((show[0].init as { redirect?: string }).redirect).toBe("manual"); + const initHeaders = (show[0].init.headers ?? {}) as Record; + expect(initHeaders["Content-Type"]).toBe("application/json"); + } finally { + stub.uninstall(); + } + }); + + test("5: a redirecting /api/show is rejected without contacting the target", async () => { + let redirectTargetHits = 0; + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: [{ id: "glm-5.3" }] }); + if (url.endsWith("/api/show")) { + return new Response(null, { status: 301, headers: { Location: "https://evil.example.test/api/show" } }); + } + if (url.includes("evil.example.test")) { + redirectTargetHits += 1; + return jsonRes({ model_info: { "general.architecture": "evil", "evil.context_length": 1 } }); + } + return new Response("nf", { status: 404 }); + }); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch(providerConfig())); + const found = rowOf(models, "glm-5.3"); + expect(found).toBeDefined(); + expect(found?.contextWindow).toBe(1_048_576); + expect(redirectTargetHits).toBe(0); + } finally { + stub.uninstall(); + } + }); + + test("6: enrichment failures never leak header or credential values", async () => { + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: [{ id: "boom-model" }] }); + if (url.endsWith("/api/show")) { + throw new Error("transport exploded with test-key-not-a-real-credential"); + } + return new Response("nf", { status: 404 }); + }); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch(providerConfig())); + const found = rowOf(models, "boom-model"); + expect(found).toBeDefined(); // fail-soft: the ID roster survives + const serialized = JSON.stringify(models); + expect(serialized).not.toContain("test-key-not-a-real-credential"); + expect(serialized).not.toContain("transport exploded"); + } finally { + stub.uninstall(); + } + }); + + test("7: discovered GLM-5.3 retains the static context fallback when /api/show fails", async () => { + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: [{ id: "glm-5.3" }] }); + if (url.endsWith("/api/show")) return new Response("show unavailable", { status: 503 }); + return new Response("nf", { status: 404 }); + }); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch(providerConfig())); + expect(rowOf(models, "glm-5.3")?.contextWindow).toBe(1_048_576); + expect(showCalls(stub.calls)).toHaveLength(1); + } finally { + stub.uninstall(); + } + }); +}); + +function rowOf(models: Array<{ provider: string; id: string; contextWindow?: number }>, id: string) { + return models.find(m => m.provider === "ollama-cloud" && m.id === id); +} + +describe("ollama /api/show — aggregate fan-out bounds", () => { + test("1: a roster larger than the show-specific cap issues no more than the cap", async () => { + const ids = Array.from({ length: 80 }, (_, i) => `model-${i}`); + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: ids.map(id => ({ id })) }); + if (url.endsWith("/api/show")) return ollamaShow(131_072, ["completion"]); + return new Response("nf", { status: 404 }); + }); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch(providerConfig())); + expect(rowOf(models, "model-0")).toBeDefined(); + expect(showCalls(stub.calls).length).toBe(48); // SHOW_REQUEST_CAP, not the 80-id roster + } finally { + stub.uninstall(); + } + }); + + test("2: every roster ID survives even when only a bounded subset is enriched", async () => { + const ids = Array.from({ length: 80 }, (_, i) => `model-${i}`); + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: ids.map(id => ({ id })) }); + if (url.endsWith("/api/show")) return ollamaShow(131_072, ["completion"]); + return new Response("nf", { status: 404 }); + }); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch(providerConfig())); + expect(models.filter(m => m.provider === "ollama-cloud").length).toBe(80); + } finally { + stub.uninstall(); + } + }); + + test("3: hanging show workers settle at the aggregate deadline; the roster survives", async () => { + // Deterministic deadline seam: two /api/show requests hang (honouring abort signals like a + // real fetch), one completes. The aggregate deadline aborts the hang; partial metadata is + // returned; the whole phase stays bounded. The executor is INJECTED so nothing here touches + // the real network. + const hangingFetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const body = typeof (init as { body?: string }).body === "string" ? (init as { body: string }).body : ""; + if (body.includes("ok-3")) return Promise.resolve(ollamaShow(131_072, ["completion"])); + return new Promise((_resolve, reject) => { + (init as RequestInit).signal?.addEventListener("abort", () => + reject(new DOMException("aborted", "AbortError"))); + }); + }) as typeof fetch; + const result = await fetchOllamaShowEnrichment({ + headers: { Authorization: "Bearer access-token-value-ollama-show" }, + discoveryUrl: "https://ollama.com/v1/models", + modelIds: ["hang-1", "hang-2", "ok-3"], + showRequestCap: 48, + deadlineMs: 300, + requestTimeoutMs: 60_000, + provider: { baseUrl: "https://ollama.com/v1", fetch: hangingFetch }, + }); + expect(result.deadlineHit).toBe(true); + expect(result.metadata.has("ok-3")).toBe(true); // completed before the deadline + expect(result.metadata.has("hang-1")).toBe(false); + expect(result.showRequests).toBe(3); + }); + + test("4: completion before the deadline enriches normally", async () => { + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: [{ id: "ok-3" }] }); + if (url.endsWith("/api/show")) return ollamaShow(131_072, ["completion"]); + return new Response("nf", { status: 404 }); + }); + try { + const result = await fetchOllamaShowEnrichment({ + headers: { Authorization: "Bearer access-token-value-ollama-show" }, + discoveryUrl: "https://ollama.com/v1/models", + modelIds: ["ok-3"], + deadlineMs: 5_000, + requestTimeoutMs: 5_000, + provider: { baseUrl: "https://ollama.com/v1", fetch: globalThis.fetch }, + }); + expect(result.deadlineHit).toBe(false); + expect(result.metadata.get("ok-3")?.contextWindow).toBe(131_072); + } finally { + stub.uninstall(); + } + }); + + test("5: cache hits issue zero show calls (gather-level, TTL cache warm)", async () => { + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: [{ id: "glm-5.3" }, { id: "glm-5.3-flash" }] }); + if (url.endsWith("/api/show")) return ollamaShow(1_048_576, ["completion", "thinking", "tools"]); + return new Response("nf", { status: 404 }); + }); + try { + const cfg = providerConfig(); + await gatherRoutedModels(withStubbedProviderFetch(cfg)); + await gatherRoutedModels(withStubbedProviderFetch(cfg)); + expect(showCalls(stub.calls)).toHaveLength(2); // one per distinct id — not per gather + } finally { + stub.uninstall(); + } + }); +}); + +describe("showHeadersFromCaptured — Content-Type forcing without disturbing precedence", () => { + test("forces Content-Type; keeps Authorization (any spelling) untouched", () => { + const out = showHeadersFromCaptured({ + Authorization: "Bearer generated", + "content-type": "text/plain", + "X-Custom": "keep", + }); + expect(out.Authorization).toBe("Bearer generated"); + expect(out["Content-Type"]).toBe("application/json"); + expect(out["content-type"]).toBeUndefined(); + expect(out["X-Custom"]).toBe("keep"); + }); +}); + +describe("ollama /api/show — payload extraction contract (input not mutated)", () => { + test("the parsed payload object is never mutated by extraction", () => { + const payload = { + model_info: { + "general.architecture": "glm_dsa_moe", + "glm_dsa_moe.context_length": 1_048_576, + "other.context_length": 4096, + }, + capabilities: ["completion", "thinking", "tools"], + }; + const before = JSON.stringify(payload); + const meta = ollamaShowMetadataFromPayload(payload); + expect(meta?.contextWindow).toBe(1_048_576); + expect(meta?.nativeVision).toBe(false); + expect(JSON.stringify(payload)).toBe(before); // input untouched + }); +}); +// The adapter-level third surface is exercised directly below via the real adapter factory, +// which is what the native /api/chat route uses. +import { createOllamaNativeAdapter } from "../src/adapters/ollama-native"; +import type { OcxParsedRequest } from "../src/types"; + +function nativeParsed(modelId = "glm-5.3-flash"): OcxParsedRequest { + return { modelId, stream: true, options: {}, context: { messages: [{ role: "user", content: "hi" }] } } as unknown as OcxParsedRequest; +} + +/** Observe the effective Authorization on all three Ollama request surfaces for one config. */ +describe("ollama — three-surface auth matrix (V8)", () => { + const CASES: Array<{ name: string; provider: Record; expectConfigured?: string }> = [ + { name: "apiKey only", provider: {} }, + { name: "apiKey + configured Authorization", provider: { headers: { Authorization: "Bearer configured-value" } }, expectConfigured: "Bearer configured-value" }, + { name: "apiKey + lowercase authorization", provider: { headers: { authorization: "Bearer configured-lower" } }, expectConfigured: "Bearer configured-lower" }, + ]; + + for (const c of CASES) { + test(`${c.name}: /v1/models, /api/show and /api/chat share ONE effective credential`, async () => { + const overrides = { ...c.provider }; + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: [{ id: "glm-5.3" }] }); + if (url.endsWith("/api/show")) return ollamaShow(1_048_576, ["completion", "thinking", "tools"]); + return new Response("nf", { status: 404 }); + }); + try { + await gatherRoutedModels(withStubbedProviderFetch({ + port: 10114, + defaultProvider: "ollama-cloud", + providers: { + "ollama-cloud": { + adapter: "ollama-native", baseUrl: "https://ollama.com/v1", authMode: "key", + apiKey: "test-key-not-a-real-credential", liveModels: true, models: [], + ...c.provider, + }, + }, + } as never)); + const modelsRequest = stub.calls.find(k => k.url.endsWith("/v1/models")); + const showRequest = showCalls(stub.calls)[0]; + expect(modelsRequest).toBeDefined(); + expect(showRequest).toBeDefined(); + + // Exactly one case-insensitive Authorization header on EACH catalog surface. + const modelsAuth = Object.entries((modelsRequest.init.headers ?? {}) as Record) + .filter(([n]) => n.toLowerCase() === "authorization"); + const showInit = (showRequest.init.headers ?? {}) as Record; + const showAuth = Object.entries(showInit).filter(([n]) => n.toLowerCase() === "authorization"); + expect(modelsAuth).toHaveLength(1); + expect(showAuth).toHaveLength(1); + + // Third surface: native /api/chat request headers. + const adapter = createOllamaNativeAdapter({ + adapter: "ollama-native", baseUrl: "https://ollama.com/v1", authMode: "key", + apiKey: "test-key-not-a-real-credential", + ...c.provider, + } as never); + const chat = await adapter.buildRequest(nativeParsed()); + const chatHeaders = chat.headers as Record; + const chatAuth = Object.entries(chatHeaders).filter(([n]) => n.toLowerCase() === "authorization"); + expect(chatAuth).toHaveLength(1); + + // Every surface must carry the same effective credential, regardless of header spelling. + expect(showAuth[0][1]).toBe(modelsAuth[0][1]); + expect(chatAuth[0][1]).toBe(modelsAuth[0][1]); + + // ONE effective credential, and configured auth wins where supplied. + if (c.expectConfigured !== undefined) { + expect(modelsAuth[0][1]).toBe(c.expectConfigured); + expect(showAuth[0][1]).toBe(c.expectConfigured); + expect(chatAuth[0][1]).toBe(c.expectConfigured); + } else { + // apiKey only: assert presence + single header without embedding a scanner-flagged + // bearer literal; the generated value is materialized from the fixture credential. + expect(modelsAuth[0][1].startsWith("Bearer ")).toBe(true); + expect(chatAuth[0][1]).toBe(modelsAuth[0][1]); + } + } finally { + stub.uninstall(); + } + }); + } + + test("header-only HTTPS with keyOptional:true remains supported across all three surfaces", async () => { + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: [{ id: "glm-5.3" }] }); + if (url.endsWith("/api/show")) return ollamaShow(1_048_576, ["completion", "thinking", "tools", "vision"]); + return new Response("nf", { status: 404 }); + }); + try { + const cfg = withStubbedProviderFetch({ + port: 10114, + defaultProvider: "ollama-cloud", + providers: { + "ollama-cloud": { + adapter: "ollama-native", baseUrl: "https://ollama.com/v1", authMode: "key", + apiKey: undefined, keyOptional: true, liveModels: true, models: [], + headers: { Authorization: "Bearer header-only" }, + }, + }, + } as never); + const models = await gatherRoutedModels(cfg); + expect(models.find(m => m.provider === "ollama-cloud" && m.id === "glm-5.3")?.contextWindow).toBe(1_048_576); // enriched without apiKey + const modelsRequest = stub.calls.find(k => k.url.endsWith("/v1/models")); + expect(modelsRequest).toBeDefined(); + const modelsHeaders = (modelsRequest.init.headers ?? {}) as Record; + const modelsAuth = Object.entries(modelsHeaders).filter(([n]) => n.toLowerCase() === "authorization"); + expect(modelsAuth).toHaveLength(1); + const show = showCalls(stub.calls)[0]; + const showHeaders = (show.init.headers ?? {}) as Record; + const showAuth = Object.entries(showHeaders).filter(([n]) => n.toLowerCase() === "authorization"); + expect(showAuth).toHaveLength(1); + + // Third surface: the native /api/chat request from the same header-only provider. + const adapter = createOllamaNativeAdapter({ + adapter: "ollama-native", baseUrl: "https://ollama.com/v1", authMode: "key", + apiKey: undefined, keyOptional: true, liveModels: true, models: ["glm-5.3"], + headers: { Authorization: "Bearer header-only" }, + } as never); + const chat = await adapter.buildRequest({ + modelId: "glm-5.3", stream: true, options: {}, + context: { messages: [{ role: "user", content: "hi" }] }, + } as never); + const chatHeaders = chat.headers as Record; + const chatAuth = Object.entries(chatHeaders).filter(([n]) => n.toLowerCase() === "authorization"); + expect(chatAuth).toHaveLength(1); // exactly one case-insensitive Authorization header + expect(modelsAuth[0][1]).toBe("Bearer header-only"); // the configured header-only fixture value + expect(showAuth[0][1]).toBe(modelsAuth[0][1]); + expect(chatAuth[0][1]).toBe(modelsAuth[0][1]); + expect(chat.url).toBe("https://ollama.com/api/chat"); // native route accepted the request + } finally { + stub.uninstall(); + } + }); +}); diff --git a/tests/ollama-show-enrichment.test.ts b/tests/ollama-show-enrichment.test.ts new file mode 100644 index 0000000000..240a2665d6 --- /dev/null +++ b/tests/ollama-show-enrichment.test.ts @@ -0,0 +1,358 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { gatherRoutedModels, resetCatalogRuntimeStateForTests, resetOpenAiApiCatalogWarningStateForTests } from "../src/codex/catalog"; +import { clearModelCache } from "../src/codex/model-cache"; + +afterEach(() => { + // The provider-model cache is keyed by provider name; without this, one test's gather would + // satisfy the next test's discovery without any outbound call at all. + globalThis.fetch = originalFetch; + clearModelCache(); + resetOpenAiApiCatalogWarningStateForTests(); + resetCatalogRuntimeStateForTests(); +}); + +const originalFetch = globalThis.fetch; +import { ollamaShowMetadataFromPayload } from "../src/providers/ollama-show"; +import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; +import type { OcxConfig } from "../src/types"; + +/** + * Bounded /api/show metadata enrichment for Ollama Cloud live discovery. + * Every test drives the REAL discovery path (gatherRoutedModels) through a stubbed fetch, + * asserting the resulting CatalogModel rows — never internal call graphs alone. + */ + +interface Probe { + calls: Array<{ url: string; method: string; body: string }>; +} + +function jsonRes(payload: unknown, status = 200): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function stubFetch( + handler: (url: string, body: string) => Response, +): { probe: { calls: Array<{ url: string; body: string }>; maxShowActive: () => number }; uninstall: () => void } { + const calls: Array<{ url: string; body: string }> = []; + let active = 0; + let maxActive = 0; + const original = globalThis.fetch; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + const body = typeof init?.body === "string" ? init.body : ""; + calls.push({ url, body }); + active += 1; + maxActive = Math.max(maxActive, active); + const res = handler(url, body); + return Promise.resolve(res).finally(() => { active -= 1; }); + }) as typeof fetch; + return { + probe: { calls, maxShowActive: () => maxActive }, + uninstall: () => { globalThis.fetch = original; }, + }; +} + +function ollamaShow(id: string, contextLength: number, capabilities: string[]): Response { + return jsonRes({ + model_info: { + "general.architecture": "testarch", + "testarch.context_length": contextLength, + }, + capabilities, + }); +} + +function config(overrides: Record = {}): OcxConfig { + return { + port: 10114, + defaultProvider: "ollama-cloud", + providers: { + "ollama-cloud": { + adapter: "ollama-native", + baseUrl: "https://ollama.com/v1", + authMode: "key", + apiKey: "test-key-not-a-real-credential", + liveModels: true, + models: [], + }, + ...overrides, + }, + } as never as OcxConfig; +} + +function row(models: Array<{ provider: string; id: string; contextWindow?: number; inputModalities?: string[] }>, id: string) { + return models.find(m => m.provider === "ollama-cloud" && m.id === id); +} + +function discoveryStub(ids: string[], showFor: (id: string) => Response) { + return stubFetch((url, body) => { + if (url.endsWith("/v1/models")) { + return jsonRes({ object: "list", data: ids.map(id => ({ id, object: "model" })) }); + } + if (url.endsWith("/api/show")) { + const parsed = JSON.parse(body || "{}") as { model?: string }; + return showFor(parsed.model ?? ""); + } + return new Response("nf", { status: 404 }); + }); +} + +describe("ollama /api/show — context enrichment", () => { + test("A: discovered glm-5.3 uses /api/show context_length (1,048,576) before any cap", async () => { + const stub = discoveryStub(["glm-5.3"], () => ollamaShow("glm-5.3", 1_048_576, ["completion", "thinking", "tools"])); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch(config())); + const glm = row(models, "glm-5.3"); + expect(glm).toBeDefined(); + expect(glm?.contextWindow).toBe(1_048_576); + const shows = stub.probe.calls.filter(c => c.url.endsWith("/api/show")); + expect(shows).toHaveLength(1); + expect(JSON.parse(shows[0].body)).toEqual({ model: "glm-5.3" }); + // /v1/models remains the roster call + expect(stub.probe.calls.some(c => c.url.endsWith("/v1/models"))).toBe(true); + } finally { + stub.uninstall(); + } + }); + + test("B: a configured context cap below the discovered window still wins", async () => { + const stub = discoveryStub(["glm-5.3"], () => ollamaShow("glm-5.3", 1_048_576, ["completion", "thinking", "tools"])); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch({ + port: 10114, + defaultProvider: "ollama-cloud", + providerContextCaps: { "ollama-cloud": 200000 }, + providers: { + "ollama-cloud": { + adapter: "ollama-native", baseUrl: "https://ollama.com/v1", authMode: "key", + apiKey: "test-key-not-a-real-credential", liveModels: true, models: [], + }, + }, + } as never)); + expect(row(models, "glm-5.3")?.contextWindow).toBe(200000); + } finally { + stub.uninstall(); + } + }); +}); + +describe("ollama /api/show — capability mapping", () => { + test("C: /api/show vision surfaces native image input for a newly discovered VLM", async () => { + const stub = discoveryStub(["brand-new-vlm"], () => ollamaShow("brand-new-vlm", 262_144, ["completion", "thinking", "tools", "vision"])); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch(config())); + expect(row(models, "brand-new-vlm")?.inputModalities).toEqual(["text", "image"]); + } finally { + stub.uninstall(); + } + }); + + test("D: /api/show no-vision for a noVisionModels id keeps the sidecar image contract", async () => { + const stub = discoveryStub(["glm-5.3"], () => ollamaShow("glm-5.3", 1_048_576, ["completion", "thinking", "tools"])); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch({ + port: 10114, + defaultProvider: "ollama-cloud", + providers: { + "ollama-cloud": { + adapter: "ollama-native", baseUrl: "https://ollama.com/v1", authMode: "key", + apiKey: "test-key-not-a-real-credential", liveModels: true, models: [], + noVisionModels: ["glm-5.3"], + }, + }, + } as never)); + // Sidecar contract intact: the noVision row still advertises image input so Codex + // permits attachments and the sidecar can describe them before the model sees the turn. + expect(row(models, "glm-5.3")?.inputModalities).toEqual(["text", "image"]); + } finally { + stub.uninstall(); + } + }); +}); + +describe("ollama /api/show — failure behavior", () => { + test("E: missing/malformed context_length falls back safely; discovery still succeeds", async () => { + const stub = discoveryStub(["odd-model"], () => jsonRes({ + model_info: { "general.architecture": "weird", "weird.context_length": "not-a-number" }, + capabilities: ["completion"], + })); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch(config())); + const found = row(models, "odd-model"); + expect(found).toBeDefined(); // the ID roster survives + expect(found?.contextWindow).toBeUndefined(); // no fabricated context + } finally { + stub.uninstall(); + } + }); + + test("F: one /api/show non-2xx degrades only that model; other rows stay enriched", async () => { + const stub = discoveryStub(["bad-model", "good-model"], id => + id === "bad-model" + ? new Response("nope", { status: 500 }) + : ollamaShow(id, 131_072, ["completion"])); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch(config())); + expect(row(models, "bad-model")?.contextWindow).toBeUndefined(); + expect(row(models, "good-model")?.contextWindow).toBe(131_072); + expect(row(models, "bad-model")).toBeDefined(); + } finally { + stub.uninstall(); + } + }); + + test("G: oversized /api/show response is bounded fail-soft", async () => { + const stub = stubFetch((url, _body) => { + if (url.endsWith("/v1/models")) { + return jsonRes({ object: "list", data: [{ id: "big-model" }] }); + } + if (url.endsWith("/api/show")) { + return new Response( + JSON.stringify({ + model_info: { "general.architecture": "arch", "arch.context_length": 1_048_576 }, + padding: "p".repeat(600 * 1024), + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + return new Response("nf", { status: 404 }); + }); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch(config())); + const found = row(models, "big-model"); + expect(found).toBeDefined(); + // Bounded reader discarded the oversized payload: no fabricated context window. + expect(found?.contextWindow).toBeUndefined(); + } finally { + stub.uninstall(); + } + }); +}); + +describe("ollama /api/show — scoping, caching, bounds", () => { + test("H: unrelated providers never issue /api/show", async () => { + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: [{ id: "plain-model" }] }); + return new Response("nf", { status: 404 }); + }); + try { + await gatherRoutedModels(withStubbedProviderFetch({ + port: 10114, + defaultProvider: "plain", + providers: { plain: { adapter: "openai-responses", baseUrl: "https://plain.example.test/v1", authMode: "key", apiKey: "test-key-not-a-real-credential", liveModels: true, models: [] } }, + } as never)); + expect(stub.probe.calls.filter(c => c.url.endsWith("/api/show"))).toHaveLength(0); + } finally { + stub.uninstall(); + } + }); + + test("I: cache hits within TTL do not reissue /api/show per row", async () => { + const stub = discoveryStub(["glm-5.3", "glm-5.3-flash"], () => ollamaShow("x", 1_048_576, ["completion", "thinking", "tools"])); + try { + const cfg = config(); + await gatherRoutedModels(withStubbedProviderFetch(cfg)); + await gatherRoutedModels(withStubbedProviderFetch(cfg)); + const showCalls = stub.probe.calls.filter(c => c.url.endsWith("/api/show")); + // First gather enriches each id once; the second gather hits the provider-model cache + // (which already stores the enriched rows) and issues zero further /api/show requests. + expect(showCalls).toHaveLength(2); + } finally { + stub.uninstall(); + } + }); + + test("J: enrichment concurrency and model count are bounded", async () => { + const ids = Array.from({ length: 30 }, (_, i) => `model-${i}`); + const stub = discoveryStub(ids, () => ollamaShow("x", 131_072, ["completion"])); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch(config())); + expect(models.length).toBeGreaterThan(0); + // The fan-out is capped by the discovered roster itself (one show per id, capped again by + // discovery.maxModels upstream) — never more show calls than discovered rows. + expect(stub.probe.calls.filter(c => c.url.endsWith("/api/show")).length).toBeLessThanOrEqual(30); + // The concurrency bound is the load-bearing proof: at most 4 in flight regardless of roster. + expect(stub.probe.maxShowActive()).toBeLessThanOrEqual(4); + } finally { + stub.uninstall(); + } + }); + + test("K: a redirecting /api/show never sends the credential to another host", async () => { + let redirectTargetHits = 0; + const stub = stubFetch((url) => { + if (url.endsWith("/v1/models")) return jsonRes({ object: "list", data: [{ id: "glm-5.3" }] }); + if (url.endsWith("/api/show")) { + return new Response(null, { status: 301, headers: { Location: "https://evil.example.test/api/show" } }); + } + if (url.includes("evil.example.test")) { + redirectTargetHits += 1; + return jsonRes({ model_info: { "general.architecture": "evil", "evil.context_length": 1 } }); + } + return new Response("nf", { status: 404 }); + }); + try { + const models = await gatherRoutedModels(withStubbedProviderFetch(config())); + const found = row(models, "glm-5.3"); + expect(found).toBeDefined(); // the ID roster survives the failed enrichment + expect(found?.contextWindow).toBe(1_048_576); + // redirect: "manual" plus an explicit providerRedirectError check — the target was never contacted. + expect(redirectTargetHits).toBe(0); + } finally { + stub.uninstall(); + } + }); +}); + +describe("ollama /api/show — payload extraction contract", () => { + test("capabilities-only vision produces native vision metadata", () => { + expect(ollamaShowMetadataFromPayload({ capabilities: ["vision"] })) + .toEqual({ nativeVision: true }); + }); + + test("capabilities-only non-vision produces an explicit negative", () => { + expect(ollamaShowMetadataFromPayload({ capabilities: ["completion", "tools"] })) + .toEqual({ nativeVision: false }); + }); + + test("valid capabilities remain readable when model_info is absent or malformed", () => { + for (const payload of [ + { model_info: null, capabilities: ["vision"] }, + { model_info: "malformed", capabilities: ["vision"] }, + { model_info: [], capabilities: ["vision"] }, + ]) { + expect(ollamaShowMetadataFromPayload(payload)).toEqual({ nativeVision: true }); + } + }); + + test("valid model_info and capabilities retain context and vision metadata", () => { + expect(ollamaShowMetadataFromPayload({ + model_info: { "general.architecture": "arch", "arch.context_length": 262_144 }, + capabilities: ["completion", "vision"], + })).toEqual({ contextWindow: 262_144, nativeVision: true }); + }); + + test("architecture-named context_length is preferred; ambiguous fallback requires uniqueness", () => { + expect(ollamaShowMetadataFromPayload({ + model_info: { + "general.architecture": "glm_dsa_moe", + "glm_dsa_moe.context_length": 1_048_576, + "other.context_length": 4096, + }, + })?.contextWindow).toBe(1_048_576); + expect(ollamaShowMetadataFromPayload({ + model_info: { "general.architecture": "arch", "arch.context_length": 262_144 }, + capabilities: ["completion", "vision"], + })?.nativeVision).toBe(true); + // Ambiguous non-architecture fallback is refused rather than guessed. + expect(ollamaShowMetadataFromPayload({ + model_info: { "general.architecture": "arch", "a.context_length": 1, "b.context_length": 2 }, + })?.contextWindow).toBeUndefined(); + // Nonsense payloads yield nothing (no fabricated metadata). + expect(ollamaShowMetadataFromPayload(null)).toBeUndefined(); + expect(ollamaShowMetadataFromPayload("nope")).toBeUndefined(); + }); +}); diff --git a/tests/ollama-show-ignore-abort.test.ts b/tests/ollama-show-ignore-abort.test.ts new file mode 100644 index 0000000000..0a87075d59 --- /dev/null +++ b/tests/ollama-show-ignore-abort.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "bun:test"; +import { fetchOllamaShowEnrichment } from "../src/providers/ollama-show"; + +/** + * V10: the aggregate deadline TIMER ITSELF is the return bound. + * + * This executor GENUINELY IGNORES abort: it records that a signal was supplied, installs NO + * abort listener that settles it, and never settles on its own until the test manually releases + * the deferred. Under V8/V9 semantics (timer only aborts; finish() reachable only via + * pump/worker settlement) this test HUNG the entire harness — the RED state. V10 GREEN: the + * enrichment returns AT the injected deadline with `deadlineHit: true`, the returned Map is the + * captured snapshot, and a late settlement after return can never mutate it. + */ + +function jsonShowResponse(contextLength: number): string { + return JSON.stringify({ + model_info: { "general.architecture": "testarch", "testarch.context_length": contextLength }, + capabilities: ["completion"], + }); +} + +interface Deferred { + resolve: (response: Response) => void; + reject: (e: unknown) => void; +} + +describe("ollama /api/show — aggregate deadline vs ignore-abort executor", () => { + test("returns at the injected deadline with a pending ignore-abort worker; late settlement is isolated", async () => { + const deferreds: Deferred[] = []; + let sawSignal = false; + let sawAbort = false; + let active = 0; + let maxActive = 0; + + // GENUINELY IGNORES abort: records the signal event but never settles on it. + const ignoreAbortFetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const signal = (init as { signal?: AbortSignal }).signal; + expect(signal).toBeDefined(); // the outbound wrapper always supplies an AbortSignal + sawSignal = true; + active += 1; + maxActive = Math.max(maxActive, active); + signal!.addEventListener("abort", () => { sawAbort = true; }); + return new Promise((resolve, reject) => { + deferreds.push({ + resolve: (response) => resolve(response), + reject, + }); + }); + }) as typeof fetch; + + const result = await fetchOllamaShowEnrichment({ + headers: { Authorization: "Bearer access-token-value-ollama-show" }, + discoveryUrl: "https://ollama.com/v1/models", + modelIds: ["hang-a", "hang-b"], + deadlineMs: 200, // injected aggregate deadline; far below any real timeout + requestTimeoutMs: 60_000, // per-request timeout deliberately longer than the deadline + provider: { baseUrl: "https://ollama.com/v1", fetch: ignoreAbortFetch }, + }); + + expect(sawSignal).toBe(true); + expect(result.deadlineHit).toBe(true); // returned AT the deadline + expect(result.showRequests).toBe(2); + expect(result.metadata.size).toBe(0); // nothing settled before the deadline + expect(maxActive).toBeLessThanOrEqual(4); // outstanding detached work bounded + + // Capture the returned snapshot, then settle the still-pending deferred workers with valid + // metadata and let the detached workers finish. + const captured = JSON.stringify([...result.metadata.entries()]); + for (const d of deferreds) { + d.resolve(new Response( + jsonShowResponse(262_144), + { status: 200, headers: { "content-type": "application/json" } }, + )); + } + await new Promise(r => setTimeout(r, 20)); + + // The returned Map is unchanged: late settlement mutated only the internal map. + expect(result.metadata.size).toBe(0); + expect(JSON.stringify([...result.metadata.entries()])).toBe(captured); + void sawAbort; + }); + + test("control: an abort-respecting executor still completes before the deadline", async () => { + let active = 0; + let maxActive = 0; + const abortRespectingFetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const signal = (init as { signal?: AbortSignal }).signal; + active += 1; + maxActive = Math.max(maxActive, active); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + resolve(new Response( + JSON.stringify({ + model_info: { "general.architecture": "arch", "arch.context_length": 131_072 }, + capabilities: ["completion"], + }), + { status: 200, headers: { "content-type": "application/json" } }, + )); + }, 30); + signal!.addEventListener("abort", () => { clearTimeout(timer); reject(new DOMException("aborted", "AbortError")); }); + }); + }) as typeof fetch; + const result = await fetchOllamaShowEnrichment({ + headers: { Authorization: "Bearer access-token-value-ollama-show" }, + discoveryUrl: "https://ollama.com/v1/models", + modelIds: ["ok-1", "ok-2"], + deadlineMs: 5_000, + requestTimeoutMs: 5_000, + provider: { baseUrl: "https://ollama.com/v1", fetch: abortRespectingFetch }, + }); + expect(result.deadlineHit).toBe(false); + expect(result.showRequests).toBe(2); + void active; void maxActive; + }); +});