Merge pull request #710 from php-workx/feat/ollama-provider
feat(ai): add Ollama Cloud provider with native ollama-chat support
This commit is contained in:
+31
-10
@@ -72,6 +72,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an
|
||||
- **Qwen Portal** (supports `QWEN_OAUTH_TOKEN` or `QWEN_PORTAL_API_KEY`)
|
||||
- **Cloudflare AI Gateway** (requires `CLOUDFLARE_AI_GATEWAY_API_KEY` and provider-specific gateway base URL)
|
||||
- **Ollama** (local OpenAI-compatible runtime; optional `OLLAMA_API_KEY`)
|
||||
- **Ollama Cloud** (hosted native Ollama API; requires `OLLAMA_CLOUD_API_KEY`)
|
||||
- **llama.cpp** (local OpenAI and Anthropic compatible inference server)
|
||||
- **vLLM** (OpenAI-compatible server; `VLLM_API_KEY` for secured deployments)
|
||||
- **GitHub Copilot** (requires OAuth, see below)
|
||||
@@ -690,13 +691,14 @@ console.log(`Using ${model.name} via ${model.api} API`);
|
||||
|
||||
### Custom Models
|
||||
|
||||
You can create custom models for local inference servers or custom endpoints:
|
||||
For Ollama, `OLLAMA_API_KEY` is optional and mainly needed for authenticated/self-hosted gateways.
|
||||
You can create custom models for local inference servers or custom endpoints.
|
||||
|
||||
For local Ollama, `OLLAMA_API_KEY` is optional and mainly needed for authenticated/self-hosted gateways. `ollama` remains the local OpenAI-compatible runtime integration.
|
||||
|
||||
```typescript
|
||||
import { Model, stream } from "@oh-my-pi/pi-ai";
|
||||
|
||||
// Example: Ollama using OpenAI-compatible API
|
||||
// Example: local Ollama using the OpenAI-compatible API
|
||||
const ollamaModel: Model<"openai-completions"> = {
|
||||
id: "llama-3.1-8b",
|
||||
name: "Llama 3.1 8B (Ollama)",
|
||||
@@ -710,6 +712,28 @@ const ollamaModel: Model<"openai-completions"> = {
|
||||
maxTokens: 32000,
|
||||
};
|
||||
|
||||
const localResponse = await stream(ollamaModel, context, {
|
||||
apiKey: process.env.OLLAMA_API_KEY, // Optional; local Ollama usually runs without auth
|
||||
});
|
||||
|
||||
// Example: Ollama Cloud using the native /api/chat transport
|
||||
const ollamaCloudModel: Model<"ollama-chat"> = {
|
||||
id: "gpt-oss:120b",
|
||||
name: "GPT OSS 120B (Ollama Cloud)",
|
||||
api: "ollama-chat",
|
||||
provider: "ollama-cloud",
|
||||
baseUrl: "https://ollama.com",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 262144,
|
||||
maxTokens: 8192,
|
||||
};
|
||||
|
||||
const cloudResponse = await stream(ollamaCloudModel, context, {
|
||||
apiKey: process.env.OLLAMA_CLOUD_API_KEY,
|
||||
});
|
||||
|
||||
// Example: LiteLLM proxy with explicit compat settings
|
||||
const litellmModel: Model<"openai-completions"> = {
|
||||
id: "gpt-4o",
|
||||
@@ -744,11 +768,6 @@ const proxyModel: Model<"anthropic-messages"> = {
|
||||
"X-Custom-Auth": "bearer-token-here",
|
||||
},
|
||||
};
|
||||
|
||||
// Use the custom model
|
||||
const response = await stream(ollamaModel, context, {
|
||||
apiKey: process.env.OLLAMA_API_KEY, // Optional; local Ollama usually runs without auth
|
||||
});
|
||||
```
|
||||
|
||||
### OpenAI Compatibility Settings
|
||||
@@ -928,6 +947,7 @@ In Node.js environments, you can set environment variables to avoid passing API
|
||||
| OpenRouter | `OPENROUTER_API_KEY` |
|
||||
| LiteLLM | `LITELLM_API_KEY` |
|
||||
| Ollama | `OLLAMA_API_KEY` (optional for local deployments) |
|
||||
| Ollama Cloud | `OLLAMA_CLOUD_API_KEY` |
|
||||
| Qwen Portal | `QWEN_OAUTH_TOKEN` or `QWEN_PORTAL_API_KEY` |
|
||||
| zAI | `ZAI_API_KEY` |
|
||||
| MiniMax Code | `MINIMAX_CODE_API_KEY` (international) or `MINIMAX_CODE_CN_API_KEY` (China) |
|
||||
@@ -957,7 +977,8 @@ Provider endpoint defaults for the current OpenAI-compatible integrations:
|
||||
- ZenMux (OpenAI): `https://zenmux.ai/api/v1`
|
||||
- ZenMux (Anthropic models): `https://zenmux.ai/api/anthropic`
|
||||
- vLLM: `http://127.0.0.1:8000/v1`
|
||||
- Ollama: local OpenAI-compatible runtime
|
||||
- Ollama: local OpenAI-compatible runtime (`http://127.0.0.1:11434/v1`)
|
||||
- Ollama Cloud: native Ollama API host (`https://ollama.com/api`, configured here as base URL `https://ollama.com`)
|
||||
- LiteLLM: `http://localhost:4000/v1`
|
||||
- Cloudflare AI Gateway: `https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/anthropic`
|
||||
- Qwen Portal: `https://portal.qwen.ai/v1`
|
||||
@@ -1049,7 +1070,7 @@ Credentials are saved to `agent.db` in the agent directory. `/login qianfan` ope
|
||||
|
||||
`login` supports OAuth providers (Anthropic, OpenAI Codex, GitHub Copilot, Gemini CLI, Antigravity) and API-key onboarding flows.
|
||||
|
||||
For the current OpenAI-compatible integrations, API-key onboarding covers Together, Moonshot, Qianfan, NVIDIA, NanoGPT, Hugging Face, Venice, Xiaomi, vLLM, LiteLLM, Cloudflare AI Gateway, and Qwen Portal. Ollama is typically local and unauthenticated; set `OLLAMA_API_KEY` only when your Ollama deployment enforces bearer auth.
|
||||
For the current API-key onboarding flows, the library covers Together, Moonshot, Qianfan, NVIDIA, NanoGPT, Hugging Face, Venice, Xiaomi, vLLM, LiteLLM, Cloudflare AI Gateway, Qwen Portal, and Ollama Cloud. Ollama remains the local runtime integration; set `OLLAMA_API_KEY` only when your local or self-hosted deployment enforces bearer auth.
|
||||
|
||||
### Programmatic OAuth
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ const BUILTIN_APIS = new Set<KnownApi>([
|
||||
"google-generative-ai",
|
||||
"google-gemini-cli",
|
||||
"google-vertex",
|
||||
"ollama-chat",
|
||||
"cursor-agent",
|
||||
]);
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ import { loginMoonshot } from "./utils/oauth/moonshot";
|
||||
import { loginNanoGPT } from "./utils/oauth/nanogpt";
|
||||
import { loginNvidia } from "./utils/oauth/nvidia";
|
||||
import { loginOllama } from "./utils/oauth/ollama";
|
||||
import { loginOllamaCloud } from "./utils/oauth/ollama-cloud";
|
||||
import { loginOpenAICodex } from "./utils/oauth/openai-codex";
|
||||
import { loginOpenCode } from "./utils/oauth/opencode";
|
||||
import { loginParallel } from "./utils/oauth/parallel";
|
||||
@@ -838,6 +839,11 @@ export class AuthStorage {
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "ollama-cloud": {
|
||||
const apiKey = await loginOllamaCloud(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
return;
|
||||
}
|
||||
case "cerebras": {
|
||||
const apiKey = await loginCerebras(ctrl);
|
||||
await saveApiKeyCredential(apiKey);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { loginKilo } from "./utils/oauth/kilo";
|
||||
import { loginKimi } from "./utils/oauth/kimi";
|
||||
import { loginMiniMaxCode, loginMiniMaxCodeCn } from "./utils/oauth/minimax-code";
|
||||
import { loginNanoGPT } from "./utils/oauth/nanogpt";
|
||||
import { loginOllamaCloud } from "./utils/oauth/ollama-cloud";
|
||||
import { loginOpenAICodex } from "./utils/oauth/openai-codex";
|
||||
import { loginParallel } from "./utils/oauth/parallel";
|
||||
import { loginTavily } from "./utils/oauth/tavily";
|
||||
@@ -271,6 +272,23 @@ async function login(provider: OAuthProvider): Promise<void> {
|
||||
console.log(`\nAPI key saved to ~/.omp/agent/agent.db`);
|
||||
return;
|
||||
}
|
||||
case "ollama-cloud": {
|
||||
const apiKey = await loginOllamaCloud({
|
||||
onAuth(info) {
|
||||
const { url, instructions } = info;
|
||||
console.log(`\nOpen this URL in your browser:\n${url}`);
|
||||
if (instructions) console.log(instructions);
|
||||
console.log();
|
||||
},
|
||||
onPrompt(p) {
|
||||
return promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`);
|
||||
},
|
||||
});
|
||||
storage.saveApiKey(provider, apiKey);
|
||||
console.log(`\nAPI key saved to ~/.omp/agent/agent.db`);
|
||||
return;
|
||||
}
|
||||
|
||||
case "minimax-code": {
|
||||
const apiKey = await loginMiniMaxCode({
|
||||
onAuth(info) {
|
||||
@@ -347,6 +365,7 @@ Providers:
|
||||
minimax-code-cn MiniMax Coding Plan (China)
|
||||
cursor Cursor (Claude, GPT, etc.)
|
||||
zenmux ZenMux
|
||||
ollama-cloud Ollama Cloud
|
||||
|
||||
Examples:
|
||||
bunx @oh-my-pi/pi-ai login # interactive provider selection
|
||||
|
||||
@@ -16,6 +16,7 @@ export * from "./providers/google";
|
||||
export * from "./providers/google-gemini-cli";
|
||||
export * from "./providers/google-vertex";
|
||||
export * from "./providers/kimi";
|
||||
export * from "./providers/ollama";
|
||||
export type { OpenAICodexResponsesOptions } from "./providers/openai-codex-responses";
|
||||
export * from "./providers/openai-completions";
|
||||
export * from "./providers/openai-responses";
|
||||
|
||||
@@ -42445,6 +42445,107 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ollama-cloud": {
|
||||
"gemma4:31b": {
|
||||
"id": "gemma4:31b",
|
||||
"name": "Gemma 4",
|
||||
"api": "ollama-chat",
|
||||
"provider": "ollama-cloud",
|
||||
"baseUrl": "https://ollama.com",
|
||||
"reasoning": true,
|
||||
"input": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 262144,
|
||||
"maxTokens": 16384,
|
||||
"thinking": {
|
||||
"mode": "effort",
|
||||
"minLevel": "minimal",
|
||||
"maxLevel": "high"
|
||||
}
|
||||
},
|
||||
"gpt-oss:120b": {
|
||||
"id": "gpt-oss:120b",
|
||||
"name": "GPT OSS (120B)",
|
||||
"api": "ollama-chat",
|
||||
"provider": "ollama-cloud",
|
||||
"baseUrl": "https://ollama.com",
|
||||
"reasoning": true,
|
||||
"input": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 131072,
|
||||
"maxTokens": 16384,
|
||||
"thinking": {
|
||||
"mode": "effort",
|
||||
"minLevel": "minimal",
|
||||
"maxLevel": "high"
|
||||
}
|
||||
},
|
||||
"gpt-oss:20b": {
|
||||
"id": "gpt-oss:20b",
|
||||
"name": "GPT OSS (20B)",
|
||||
"api": "ollama-chat",
|
||||
"provider": "ollama-cloud",
|
||||
"baseUrl": "https://ollama.com",
|
||||
"reasoning": true,
|
||||
"input": [
|
||||
"text"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 131072,
|
||||
"maxTokens": 16384,
|
||||
"thinking": {
|
||||
"mode": "effort",
|
||||
"minLevel": "minimal",
|
||||
"maxLevel": "high"
|
||||
}
|
||||
},
|
||||
"qwen3-next:80b": {
|
||||
"id": "qwen3-next:80b",
|
||||
"name": "Qwen 3 Next (80B)",
|
||||
"api": "ollama-chat",
|
||||
"provider": "ollama-cloud",
|
||||
"baseUrl": "https://ollama.com",
|
||||
"reasoning": true,
|
||||
"input": [
|
||||
"text"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 262144,
|
||||
"maxTokens": 16384,
|
||||
"thinking": {
|
||||
"mode": "effort",
|
||||
"minLevel": "minimal",
|
||||
"maxLevel": "high"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"qianfan": {
|
||||
"deepseek-v3.2": {
|
||||
"id": "deepseek-v3.2",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { getBundledModels, getBundledProviders } from "../models";
|
||||
import type { Api, Model } from "../types";
|
||||
|
||||
export function createBundledReferenceMap<TApi extends Api>(
|
||||
provider: Parameters<typeof getBundledModels>[0],
|
||||
): Map<string, Model<TApi>> {
|
||||
const references = new Map<string, Model<TApi>>();
|
||||
for (const model of getBundledModels(provider)) {
|
||||
references.set(model.id, model as Model<TApi>);
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
export function createReferenceResolver<TApi extends Api>(
|
||||
providerRefs: Map<string, Model<TApi>>,
|
||||
): (modelId: string) => Model<TApi> | undefined {
|
||||
const globalRefs = new Map<string, Model<Api>>();
|
||||
for (const provider of getBundledProviders()) {
|
||||
for (const model of getBundledModels(provider as Parameters<typeof getBundledModels>[0])) {
|
||||
const candidate = model as Model<Api>;
|
||||
const existing = globalRefs.get(candidate.id);
|
||||
if (!existing) {
|
||||
globalRefs.set(candidate.id, candidate);
|
||||
} else if (candidate.contextWindow !== existing.contextWindow) {
|
||||
if (candidate.contextWindow > existing.contextWindow) {
|
||||
globalRefs.set(candidate.id, candidate);
|
||||
}
|
||||
} else if (candidate.maxTokens !== existing.maxTokens) {
|
||||
if (candidate.maxTokens > existing.maxTokens) {
|
||||
globalRefs.set(candidate.id, candidate);
|
||||
}
|
||||
} else if (existing.provider !== "openai" && candidate.provider === "openai") {
|
||||
globalRefs.set(candidate.id, candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (modelId: string) => providerRefs.get(modelId) ?? (globalRefs.get(modelId) as Model<TApi> | undefined);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { ModelManagerOptions } from "../model-manager";
|
||||
import type { Api, KnownProvider } from "../types";
|
||||
import type { OAuthProvider } from "../utils/oauth/types";
|
||||
import { googleModelManagerOptions } from "./google";
|
||||
import { ollamaCloudModelManagerOptions } from "./ollama";
|
||||
import {
|
||||
alibabaCodingPlanModelManagerOptions,
|
||||
anthropicModelManagerOptions,
|
||||
@@ -184,6 +185,12 @@ export const PROVIDER_DESCRIPTORS: readonly ProviderDescriptor[] = [
|
||||
catalog("Ollama", ["OLLAMA_API_KEY"]),
|
||||
{ allowUnauthenticated: true },
|
||||
),
|
||||
catalogDescriptor(
|
||||
"ollama-cloud",
|
||||
"gpt-oss:120b",
|
||||
config => ollamaCloudModelManagerOptions(config),
|
||||
catalog("Ollama Cloud", ["OLLAMA_CLOUD_API_KEY"], { oauthProvider: "ollama-cloud" }),
|
||||
),
|
||||
catalogDescriptor(
|
||||
"cloudflare-ai-gateway",
|
||||
"claude-sonnet-4-5",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./descriptors";
|
||||
export * from "./google";
|
||||
export * from "./ollama";
|
||||
export * from "./openai-compat";
|
||||
export * from "./special";
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { ModelManagerOptions } from "../model-manager";
|
||||
import { Effort } from "../model-thinking";
|
||||
import type { ThinkingConfig } from "../types";
|
||||
import { createBundledReferenceMap, createReferenceResolver } from "./bundled-references";
|
||||
|
||||
export interface OllamaCloudModelManagerConfig {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
type OllamaTagEntry = {
|
||||
name?: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
type OllamaShowResponse = {
|
||||
capabilities?: string[];
|
||||
model_info?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function trimTrailingSlash(value: string): string {
|
||||
return value.endsWith("/") ? value.slice(0, -1) : value;
|
||||
}
|
||||
|
||||
export function normalizeOllamaCloudBaseUrl(baseUrl?: string): string {
|
||||
const value = baseUrl?.trim();
|
||||
if (!value) {
|
||||
return "https://ollama.com";
|
||||
}
|
||||
const trimmed = trimTrailingSlash(value);
|
||||
return trimmed.endsWith("/api") ? trimmed.slice(0, -4) : trimmed;
|
||||
}
|
||||
|
||||
function createCloudHeaders(apiKey: string): Record<string, string> {
|
||||
return {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
};
|
||||
}
|
||||
|
||||
function getContextWindow(modelInfo: Record<string, unknown> | undefined): number | undefined {
|
||||
if (!modelInfo) {
|
||||
return undefined;
|
||||
}
|
||||
for (const [key, value] of Object.entries(modelInfo)) {
|
||||
if (typeof value !== "number") {
|
||||
continue;
|
||||
}
|
||||
if (key.endsWith(".context_length") || key.endsWith(".num_ctx") || key.endsWith(".context_window")) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getThinkingConfig(capabilities: string[] | undefined): ThinkingConfig | undefined {
|
||||
if (!capabilities?.includes("thinking")) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
mode: "effort",
|
||||
minLevel: Effort.Minimal,
|
||||
maxLevel: Effort.High,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchShowMetadata(
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
model: string,
|
||||
): Promise<OllamaShowResponse | undefined> {
|
||||
const response = await fetch(`${baseUrl}/api/show`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...createCloudHeaders(apiKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ model }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return undefined;
|
||||
}
|
||||
return (await response.json()) as OllamaShowResponse;
|
||||
}
|
||||
|
||||
export function ollamaCloudModelManagerOptions(
|
||||
config?: OllamaCloudModelManagerConfig,
|
||||
): ModelManagerOptions<"ollama-chat"> {
|
||||
const apiKey = config?.apiKey;
|
||||
const baseUrl = normalizeOllamaCloudBaseUrl(config?.baseUrl);
|
||||
const resolveReference = createReferenceResolver(createBundledReferenceMap<"ollama-chat">("ollama-cloud"));
|
||||
return {
|
||||
providerId: "ollama-cloud",
|
||||
fetchDynamicModels: async () => {
|
||||
if (!apiKey) {
|
||||
return [];
|
||||
}
|
||||
const response = await fetch(`${baseUrl}/api/tags`, {
|
||||
method: "GET",
|
||||
headers: createCloudHeaders(apiKey),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} from ${baseUrl}/api/tags`);
|
||||
}
|
||||
const payload = (await response.json()) as { models?: OllamaTagEntry[] };
|
||||
const entries = payload.models ?? [];
|
||||
const models = await Promise.all(
|
||||
entries.map(async entry => {
|
||||
const id = entry.model ?? entry.name;
|
||||
if (!id) {
|
||||
return undefined;
|
||||
}
|
||||
const reference = resolveReference(id);
|
||||
let metadata: OllamaShowResponse | undefined;
|
||||
try {
|
||||
metadata = await fetchShowMetadata(baseUrl, apiKey, id);
|
||||
} catch {
|
||||
metadata = undefined;
|
||||
}
|
||||
const capabilities = metadata?.capabilities;
|
||||
const contextWindow = getContextWindow(metadata?.model_info) ?? reference?.contextWindow ?? 128000;
|
||||
const reasoning = capabilities ? capabilities.includes("thinking") : (reference?.reasoning ?? false);
|
||||
const thinking = capabilities ? getThinkingConfig(capabilities) : reference?.thinking;
|
||||
const input = capabilities
|
||||
? capabilities.includes("vision")
|
||||
? (["text", "image"] as Array<"text" | "image">)
|
||||
: (["text"] as Array<"text">)
|
||||
: ((reference?.input as Array<"text" | "image"> | undefined) ?? (["text"] as Array<"text">));
|
||||
const resolvedName = entry.name && entry.name !== id ? entry.name : (reference?.name ?? id);
|
||||
return {
|
||||
id,
|
||||
name: resolvedName,
|
||||
api: "ollama-chat" as const,
|
||||
provider: "ollama-cloud" as const,
|
||||
baseUrl,
|
||||
reasoning,
|
||||
thinking,
|
||||
input,
|
||||
cost: reference?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow,
|
||||
maxTokens: reference?.maxTokens ?? Math.min(contextWindow, 8192),
|
||||
};
|
||||
}),
|
||||
);
|
||||
return models
|
||||
.filter((model): model is NonNullable<(typeof models)[number]> => model !== undefined)
|
||||
.sort((left, right) => left.id.localeCompare(right.id));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ModelManagerOptions } from "../model-manager";
|
||||
import { getBundledModels, getBundledProviders } from "../models";
|
||||
import { getBundledModels } from "../models";
|
||||
import type { Api, Model } from "../types";
|
||||
import { isAnthropicOAuthToken, isRecord, toNumber, toPositiveNumber } from "../utils";
|
||||
import {
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type OpenAICompatibleModelRecord,
|
||||
} from "../utils/discovery/openai-compatible";
|
||||
import { getGitHubCopilotBaseUrl, OPENCODE_HEADERS, parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
|
||||
import { createBundledReferenceMap, createReferenceResolver } from "./bundled-references";
|
||||
|
||||
const MODELS_DEV_URL = "https://models.dev/api.json";
|
||||
const ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1";
|
||||
@@ -163,48 +164,6 @@ function mapWithBundledReference<TApi extends Api>(
|
||||
};
|
||||
}
|
||||
|
||||
function createBundledReferenceMap<TApi extends Api>(
|
||||
provider: Parameters<typeof getBundledModels>[0],
|
||||
): Map<string, Model<TApi>> {
|
||||
const references = new Map<string, Model<TApi>>();
|
||||
for (const model of getBundledModels(provider)) {
|
||||
references.set(model.id, model as Model<TApi>);
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a lookup that resolves a model ID to a bundled reference, preferring
|
||||
* the provider-specific entry over a cross-provider fallback. The global fallback
|
||||
* picks the best entry across all providers (largest contextWindow, then maxTokens,
|
||||
* then canonical OpenAI), but proxy providers (Copilot, nanogpt, etc.) impose their
|
||||
* own limits that are typically lower than native provider limits, so the
|
||||
* provider-specific entry must win.
|
||||
*/
|
||||
function createReferenceResolver<TApi extends Api>(
|
||||
providerRefs: Map<string, Model<TApi>>,
|
||||
): (modelId: string) => Model<TApi> | undefined {
|
||||
const globalRefs = new Map<string, Model<Api>>();
|
||||
for (const provider of getBundledProviders()) {
|
||||
for (const model of getBundledModels(provider as Parameters<typeof getBundledModels>[0])) {
|
||||
const candidate = model as Model<Api>;
|
||||
const existing = globalRefs.get(candidate.id);
|
||||
if (!existing) {
|
||||
globalRefs.set(candidate.id, candidate);
|
||||
} else if (candidate.contextWindow !== existing.contextWindow) {
|
||||
if (candidate.contextWindow > existing.contextWindow) globalRefs.set(candidate.id, candidate);
|
||||
} else if (candidate.maxTokens !== existing.maxTokens) {
|
||||
if (candidate.maxTokens > existing.maxTokens) globalRefs.set(candidate.id, candidate);
|
||||
} else if (existing.provider !== "openai" && candidate.provider === "openai") {
|
||||
// When limits tie, prefer OpenAI as canonical so generic OpenAI-family
|
||||
// providers inherit OpenAI pricing/capabilities instead of proxy metadata.
|
||||
globalRefs.set(candidate.id, candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (modelId: string) => providerRefs.get(modelId) ?? (globalRefs.get(modelId) as Model<TApi> | undefined);
|
||||
}
|
||||
|
||||
function normalizeAnthropicBaseUrl(baseUrl: string | undefined, fallback: string): string {
|
||||
const value = baseUrl?.trim();
|
||||
if (!value) {
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
import type { TSchema } from "@sinclair/typebox";
|
||||
import { getEnvApiKey } from "../stream";
|
||||
import type {
|
||||
Api,
|
||||
AssistantMessage,
|
||||
Context,
|
||||
DeveloperMessage,
|
||||
Message,
|
||||
Model,
|
||||
StreamFunction,
|
||||
StreamOptions,
|
||||
Tool,
|
||||
ToolChoice,
|
||||
ToolResultMessage,
|
||||
UserMessage,
|
||||
} from "../types";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream";
|
||||
import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
|
||||
import { parseStreamingJson } from "../utils/json-parse";
|
||||
import { transformMessages } from "./transform-messages";
|
||||
|
||||
export interface OllamaChatOptions extends StreamOptions {
|
||||
reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
toolChoice?: ToolChoice;
|
||||
}
|
||||
|
||||
type OllamaFunctionTool = {
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: TSchema;
|
||||
};
|
||||
};
|
||||
|
||||
type OllamaMessage = {
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
content: string;
|
||||
images?: string[];
|
||||
thinking?: string;
|
||||
tool_calls?: Array<{
|
||||
type: "function";
|
||||
function: {
|
||||
index?: number;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
};
|
||||
}>;
|
||||
tool_name?: string;
|
||||
};
|
||||
|
||||
type OllamaChatChunk = {
|
||||
message?: {
|
||||
role?: string;
|
||||
content?: string;
|
||||
thinking?: string;
|
||||
tool_calls?: Array<{
|
||||
type?: string;
|
||||
function?: {
|
||||
index?: number;
|
||||
name?: string;
|
||||
arguments?: Record<string, unknown> | string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
done?: boolean;
|
||||
done_reason?: string;
|
||||
prompt_eval_count?: number;
|
||||
eval_count?: number;
|
||||
};
|
||||
|
||||
type InternalToolCallBlock = AssistantMessage["content"][number] & {
|
||||
type: "toolCall";
|
||||
partialJson?: string;
|
||||
};
|
||||
|
||||
function normalizeBaseUrl(baseUrl?: string): string {
|
||||
const value = baseUrl?.trim();
|
||||
if (!value) {
|
||||
return "https://ollama.com";
|
||||
}
|
||||
const trimmed = value.endsWith("/") ? value.slice(0, -1) : value;
|
||||
return trimmed.endsWith("/api") ? trimmed.slice(0, -4) : trimmed;
|
||||
}
|
||||
|
||||
function mapReasoning(reasoning: OllamaChatOptions["reasoning"]): boolean | "low" | "medium" | "high" | undefined {
|
||||
switch (reasoning) {
|
||||
case "minimal":
|
||||
case "low":
|
||||
return "low";
|
||||
case "medium":
|
||||
return "medium";
|
||||
case "high":
|
||||
case "xhigh":
|
||||
return "high";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function mapToolChoice(toolChoice: ToolChoice | undefined): "auto" | "none" | "required" | undefined {
|
||||
if (!toolChoice || toolChoice === "auto") {
|
||||
return undefined;
|
||||
}
|
||||
if (toolChoice === "none") {
|
||||
return "none";
|
||||
}
|
||||
if (toolChoice === "required" || toolChoice === "any") {
|
||||
return "required";
|
||||
}
|
||||
if (typeof toolChoice === "object") {
|
||||
return "required";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function toPlainContent(content: string | Array<{ type: "text" | "image"; text?: string; data?: string }>): {
|
||||
content: string;
|
||||
images?: string[];
|
||||
} {
|
||||
if (typeof content === "string") {
|
||||
return { content };
|
||||
}
|
||||
const textParts: string[] = [];
|
||||
const images: string[] = [];
|
||||
for (const block of content) {
|
||||
if (block.type === "text" && typeof block.text === "string") {
|
||||
textParts.push(block.text);
|
||||
}
|
||||
if (block.type === "image" && typeof block.data === "string") {
|
||||
images.push(block.data);
|
||||
}
|
||||
}
|
||||
return {
|
||||
content: textParts.join("\n"),
|
||||
...(images.length > 0 ? { images } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function convertMessage(message: Message): OllamaMessage {
|
||||
if (message.role === "user") {
|
||||
const converted = toPlainContent(message.content as UserMessage["content"]);
|
||||
return { role: "user", ...converted };
|
||||
}
|
||||
if (message.role === "developer") {
|
||||
const converted = toPlainContent(message.content as DeveloperMessage["content"]);
|
||||
return { role: "system", ...converted };
|
||||
}
|
||||
if (message.role === "toolResult") {
|
||||
const converted = toPlainContent(message.content as ToolResultMessage["content"]);
|
||||
return {
|
||||
role: "tool",
|
||||
tool_name: message.toolName,
|
||||
...converted,
|
||||
};
|
||||
}
|
||||
const text: string[] = [];
|
||||
const thinking: string[] = [];
|
||||
const toolCalls: NonNullable<OllamaMessage["tool_calls"]> = [];
|
||||
for (const block of message.content) {
|
||||
if (block.type === "text") {
|
||||
text.push(block.text);
|
||||
continue;
|
||||
}
|
||||
if (block.type === "thinking") {
|
||||
thinking.push(block.thinking);
|
||||
continue;
|
||||
}
|
||||
if (block.type === "toolCall") {
|
||||
toolCalls.push({
|
||||
type: "function",
|
||||
function: {
|
||||
name: block.name,
|
||||
arguments: block.arguments,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
role: "assistant",
|
||||
content: text.join("\n"),
|
||||
...(thinking.length > 0 ? { thinking: thinking.join("\n") } : {}),
|
||||
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function convertMessages(model: Model<"ollama-chat">, context: Context): OllamaMessage[] {
|
||||
const messages: Message[] = [];
|
||||
if (context.systemPrompt) {
|
||||
messages.push({
|
||||
role: "developer",
|
||||
content: context.systemPrompt,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
messages.push(...context.messages);
|
||||
return transformMessages(messages, model).map(convertMessage);
|
||||
}
|
||||
|
||||
function convertTools(tools: Tool[] | undefined): OllamaFunctionTool[] | undefined {
|
||||
if (!tools || tools.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return tools.map(tool => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function createChatBody(model: Model<"ollama-chat">, context: Context, options: OllamaChatOptions | undefined) {
|
||||
const think = mapReasoning(options?.reasoning);
|
||||
const toolChoice = mapToolChoice(options?.toolChoice);
|
||||
return {
|
||||
model: model.id,
|
||||
messages: convertMessages(model, context),
|
||||
...(convertTools(context.tools) ? { tools: convertTools(context.tools) } : {}),
|
||||
...(think !== undefined ? { think } : {}),
|
||||
...(toolChoice !== undefined ? { tool_choice: toolChoice } : {}),
|
||||
...(options?.maxTokens !== undefined ? { options: { num_predict: options.maxTokens } } : {}),
|
||||
stream: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function* iterateNdjson(stream: ReadableStream<Uint8Array>): AsyncGenerator<OllamaChatChunk> {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
while (true) {
|
||||
const newlineIndex = buffer.indexOf("\n");
|
||||
if (newlineIndex < 0) {
|
||||
break;
|
||||
}
|
||||
const line = buffer.slice(0, newlineIndex).trim();
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
yield JSON.parse(line) as OllamaChatChunk;
|
||||
}
|
||||
}
|
||||
buffer += decoder.decode();
|
||||
const tail = buffer.trim();
|
||||
if (tail) {
|
||||
yield JSON.parse(tail) as OllamaChatChunk;
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyOutput(model: Model<"ollama-chat">): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: "ollama-chat" as Api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function endThinkingBlock(stream: AssistantMessageEventStream, output: AssistantMessage, index: number): void {
|
||||
const block = output.content[index];
|
||||
if (block?.type === "thinking") {
|
||||
stream.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: output });
|
||||
}
|
||||
}
|
||||
|
||||
function endTextBlock(stream: AssistantMessageEventStream, output: AssistantMessage, index: number): void {
|
||||
const block = output.content[index];
|
||||
if (block?.type === "text") {
|
||||
stream.push({ type: "text_end", contentIndex: index, content: block.text, partial: output });
|
||||
}
|
||||
}
|
||||
|
||||
function endToolCallBlock(stream: AssistantMessageEventStream, output: AssistantMessage, index: number): void {
|
||||
const block = output.content[index];
|
||||
if (block?.type !== "toolCall") {
|
||||
return;
|
||||
}
|
||||
const toolCall = block as InternalToolCallBlock;
|
||||
if (toolCall.partialJson) {
|
||||
toolCall.arguments = parseStreamingJson<Record<string, unknown>>(toolCall.partialJson);
|
||||
delete toolCall.partialJson;
|
||||
}
|
||||
stream.push({ type: "toolcall_end", contentIndex: index, toolCall, partial: output });
|
||||
}
|
||||
|
||||
function mapDoneReason(doneReason: string | undefined, output: AssistantMessage): AssistantMessage["stopReason"] {
|
||||
if (doneReason === "length") {
|
||||
return "length";
|
||||
}
|
||||
if (doneReason === "tool_calls") {
|
||||
return "toolUse";
|
||||
}
|
||||
if (doneReason === undefined && output.content.some(block => block.type === "toolCall")) {
|
||||
return "toolUse";
|
||||
}
|
||||
return "stop";
|
||||
}
|
||||
|
||||
export const streamOllama: StreamFunction<"ollama-chat"> = (
|
||||
model: Model<"ollama-chat">,
|
||||
context: Context,
|
||||
options: OllamaChatOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const stream = new AssistantMessageEventStream();
|
||||
void (async () => {
|
||||
const startTime = Date.now();
|
||||
let firstTokenTime: number | undefined;
|
||||
const output = createEmptyOutput(model);
|
||||
let rawRequestDump: RawHttpRequestDump | undefined;
|
||||
let activeThinkingIndex: number | undefined;
|
||||
let activeTextIndex: number | undefined;
|
||||
const activeToolIndices = new Set<number>();
|
||||
try {
|
||||
const apiKey = options.apiKey || getEnvApiKey(model.provider);
|
||||
if (!apiKey) {
|
||||
throw new Error(`No API key for provider: ${model.provider}`);
|
||||
}
|
||||
const baseUrl = normalizeBaseUrl(model.baseUrl);
|
||||
let body = createChatBody(model, context, options);
|
||||
const replacementPayload = await options.onPayload?.(body, model);
|
||||
if (replacementPayload !== undefined) {
|
||||
body = replacementPayload as typeof body;
|
||||
}
|
||||
rawRequestDump = {
|
||||
provider: model.provider,
|
||||
api: model.api,
|
||||
model: model.id,
|
||||
method: "POST",
|
||||
url: `${baseUrl}/api/chat`,
|
||||
body,
|
||||
};
|
||||
const response = await fetch(`${baseUrl}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...model.headers,
|
||||
...options.headers,
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: options.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} from ${baseUrl}/api/chat`);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error("Ollama returned an empty response body");
|
||||
}
|
||||
stream.push({ type: "start", partial: output });
|
||||
for await (const chunk of iterateNdjson(response.body)) {
|
||||
if (chunk.message?.thinking) {
|
||||
if (activeTextIndex !== undefined) {
|
||||
endTextBlock(stream, output, activeTextIndex);
|
||||
activeTextIndex = undefined;
|
||||
}
|
||||
if (activeThinkingIndex === undefined) {
|
||||
output.content.push({ type: "thinking", thinking: "" });
|
||||
activeThinkingIndex = output.content.length - 1;
|
||||
stream.push({ type: "thinking_start", contentIndex: activeThinkingIndex, partial: output });
|
||||
}
|
||||
const block = output.content[activeThinkingIndex];
|
||||
if (block?.type === "thinking") {
|
||||
block.thinking += chunk.message.thinking;
|
||||
stream.push({
|
||||
type: "thinking_delta",
|
||||
contentIndex: activeThinkingIndex,
|
||||
delta: chunk.message.thinking,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
if (!firstTokenTime) {
|
||||
firstTokenTime = Date.now();
|
||||
}
|
||||
}
|
||||
if (chunk.message?.content) {
|
||||
if (activeThinkingIndex !== undefined) {
|
||||
endThinkingBlock(stream, output, activeThinkingIndex);
|
||||
activeThinkingIndex = undefined;
|
||||
}
|
||||
if (activeTextIndex === undefined) {
|
||||
output.content.push({ type: "text", text: "" });
|
||||
activeTextIndex = output.content.length - 1;
|
||||
stream.push({ type: "text_start", contentIndex: activeTextIndex, partial: output });
|
||||
}
|
||||
const block = output.content[activeTextIndex];
|
||||
if (block?.type === "text") {
|
||||
block.text += chunk.message.content;
|
||||
stream.push({
|
||||
type: "text_delta",
|
||||
contentIndex: activeTextIndex,
|
||||
delta: chunk.message.content,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
if (!firstTokenTime) {
|
||||
firstTokenTime = Date.now();
|
||||
}
|
||||
}
|
||||
if (chunk.message?.tool_calls?.length) {
|
||||
if (activeThinkingIndex !== undefined) {
|
||||
endThinkingBlock(stream, output, activeThinkingIndex);
|
||||
activeThinkingIndex = undefined;
|
||||
}
|
||||
if (activeTextIndex !== undefined) {
|
||||
endTextBlock(stream, output, activeTextIndex);
|
||||
activeTextIndex = undefined;
|
||||
}
|
||||
for (const call of chunk.message.tool_calls) {
|
||||
const name = call.function?.name ?? "unknown_tool";
|
||||
const rawArgs = call.function?.arguments;
|
||||
const partialJson = typeof rawArgs === "string" ? rawArgs : JSON.stringify(rawArgs ?? {});
|
||||
const toolCall: InternalToolCallBlock = {
|
||||
type: "toolCall",
|
||||
id: `ollama:${output.content.length}:${name}`,
|
||||
name,
|
||||
arguments: parseStreamingJson<Record<string, unknown>>(partialJson),
|
||||
partialJson,
|
||||
};
|
||||
output.content.push(toolCall);
|
||||
const index = output.content.length - 1;
|
||||
activeToolIndices.add(index);
|
||||
stream.push({ type: "toolcall_start", contentIndex: index, partial: output });
|
||||
stream.push({
|
||||
type: "toolcall_delta",
|
||||
contentIndex: index,
|
||||
delta: partialJson,
|
||||
partial: output,
|
||||
});
|
||||
if (!firstTokenTime) {
|
||||
firstTokenTime = Date.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (chunk.done) {
|
||||
if (activeThinkingIndex !== undefined) {
|
||||
endThinkingBlock(stream, output, activeThinkingIndex);
|
||||
activeThinkingIndex = undefined;
|
||||
}
|
||||
if (activeTextIndex !== undefined) {
|
||||
endTextBlock(stream, output, activeTextIndex);
|
||||
activeTextIndex = undefined;
|
||||
}
|
||||
for (const index of activeToolIndices) {
|
||||
endToolCallBlock(stream, output, index);
|
||||
}
|
||||
activeToolIndices.clear();
|
||||
output.stopReason = mapDoneReason(chunk.done_reason, output);
|
||||
output.usage.input = chunk.prompt_eval_count ?? 0;
|
||||
output.usage.output = chunk.eval_count ?? 0;
|
||||
output.usage.totalTokens = output.usage.input + output.usage.output;
|
||||
}
|
||||
}
|
||||
output.duration = Date.now() - startTime;
|
||||
if (firstTokenTime) {
|
||||
output.ttft = firstTokenTime - startTime;
|
||||
}
|
||||
const doneReason =
|
||||
output.stopReason === "length" ? "length" : output.stopReason === "toolUse" ? "toolUse" : "stop";
|
||||
stream.push({ type: "done", reason: doneReason, message: output });
|
||||
stream.end();
|
||||
} catch (error) {
|
||||
for (const block of output.content) {
|
||||
if (block.type === "toolCall") {
|
||||
delete (block as InternalToolCallBlock).partialJson;
|
||||
}
|
||||
}
|
||||
output.stopReason = options.signal?.aborted ? "aborted" : "error";
|
||||
output.errorMessage = await finalizeErrorMessage(error, rawRequestDump);
|
||||
output.duration = Date.now() - startTime;
|
||||
if (firstTokenTime) {
|
||||
output.ttft = firstTokenTime - startTime;
|
||||
}
|
||||
stream.push({ type: "error", reason: output.stopReason, error: output });
|
||||
stream.end();
|
||||
}
|
||||
})();
|
||||
return stream;
|
||||
};
|
||||
@@ -27,6 +27,7 @@ import type { CursorOptions } from "./cursor";
|
||||
import type { GoogleOptions } from "./google";
|
||||
import type { GoogleGeminiCliOptions } from "./google-gemini-cli";
|
||||
import type { GoogleVertexOptions } from "./google-vertex";
|
||||
import type { OllamaChatOptions } from "./ollama";
|
||||
import type { OpenAICodexResponsesOptions } from "./openai-codex-responses";
|
||||
import type { OpenAICompletionsOptions } from "./openai-completions";
|
||||
import type { OpenAIResponsesOptions } from "./openai-responses";
|
||||
@@ -103,6 +104,14 @@ interface OpenAIResponsesProviderModule {
|
||||
) => AssistantMessageEventStream;
|
||||
}
|
||||
|
||||
interface OllamaProviderModule {
|
||||
streamOllama: (
|
||||
model: Model<"ollama-chat">,
|
||||
context: Context,
|
||||
options: OllamaChatOptions,
|
||||
) => AssistantMessageEventStream;
|
||||
}
|
||||
|
||||
interface CursorProviderModule {
|
||||
streamCursor: (
|
||||
model: Model<"cursor-agent">,
|
||||
@@ -133,6 +142,7 @@ let googleVertexProviderModulePromise: Promise<LazyProviderModule<"google-vertex
|
||||
let openAICodexResponsesProviderModulePromise: Promise<LazyProviderModule<"openai-codex-responses">> | undefined;
|
||||
let openAICompletionsProviderModulePromise: Promise<LazyProviderModule<"openai-completions">> | undefined;
|
||||
let openAIResponsesProviderModulePromise: Promise<LazyProviderModule<"openai-responses">> | undefined;
|
||||
let ollamaProviderModulePromise: Promise<LazyProviderModule<"ollama-chat">> | undefined;
|
||||
let cursorProviderModulePromise: Promise<LazyProviderModule<"cursor-agent">> | undefined;
|
||||
let bedrockProviderModuleOverride: LazyProviderModule<"bedrock-converse-stream"> | undefined;
|
||||
let bedrockProviderModulePromise: Promise<LazyProviderModule<"bedrock-converse-stream">> | undefined;
|
||||
@@ -290,6 +300,14 @@ function loadOpenAIResponsesProviderModule(): Promise<LazyProviderModule<"openai
|
||||
return openAIResponsesProviderModulePromise;
|
||||
}
|
||||
|
||||
function loadOllamaProviderModule(): Promise<LazyProviderModule<"ollama-chat">> {
|
||||
ollamaProviderModulePromise ||= import("./ollama").then(module => {
|
||||
const provider = module as OllamaProviderModule;
|
||||
return { stream: provider.streamOllama };
|
||||
});
|
||||
return ollamaProviderModulePromise;
|
||||
}
|
||||
|
||||
function loadCursorProviderModule(): Promise<LazyProviderModule<"cursor-agent">> {
|
||||
cursorProviderModulePromise ||= import("./cursor").then(module => {
|
||||
const provider = module as CursorProviderModule;
|
||||
@@ -326,4 +344,6 @@ export const streamOpenAICodexResponses = createLazyStream(loadOpenAICodexRespon
|
||||
export const streamOpenAICompletions = createLazyStream(loadOpenAICompletionsProviderModule);
|
||||
export const streamOpenAIResponses = createLazyStream(loadOpenAIResponsesProviderModule);
|
||||
export const streamCursor = createLazyStream(loadCursorProviderModule);
|
||||
export const streamOllama = createLazyStream(loadOllamaProviderModule);
|
||||
|
||||
export const streamBedrock = createLazyStream(loadBedrockProviderModule);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { type GoogleOptions, streamGoogle } from "./providers/google";
|
||||
import { type GoogleGeminiCliOptions, streamGoogleGeminiCli } from "./providers/google-gemini-cli";
|
||||
import { type GoogleVertexOptions, streamGoogleVertex } from "./providers/google-vertex";
|
||||
import { isKimiModel, streamKimi } from "./providers/kimi";
|
||||
import { type OllamaChatOptions, streamOllama } from "./providers/ollama";
|
||||
import { streamOpenAICodexResponses } from "./providers/openai-codex-responses";
|
||||
import { type OpenAICompletionsOptions, streamOpenAICompletions } from "./providers/openai-completions";
|
||||
import { streamOpenAIResponses } from "./providers/openai-responses";
|
||||
@@ -131,6 +132,7 @@ const serviceProviderMap: Record<string, KeyResolver> = {
|
||||
nanogpt: "NANO_GPT_API_KEY",
|
||||
"lm-studio": "LM_STUDIO_API_KEY",
|
||||
ollama: "OLLAMA_API_KEY",
|
||||
"ollama-cloud": "OLLAMA_CLOUD_API_KEY",
|
||||
"llama.cpp": "LLAMA_CPP_API_KEY",
|
||||
qianfan: "QIANFAN_API_KEY",
|
||||
"qwen-portal": () => $pickenv("QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"),
|
||||
@@ -218,6 +220,9 @@ export function stream<TApi extends Api>(
|
||||
providerOptions as GoogleGeminiCliOptions,
|
||||
);
|
||||
|
||||
case "ollama-chat":
|
||||
return streamOllama(model as Model<"ollama-chat">, context, providerOptions as OllamaChatOptions);
|
||||
|
||||
case "cursor-agent":
|
||||
return streamCursor(model as Model<"cursor-agent">, context, providerOptions as CursorOptions);
|
||||
|
||||
@@ -677,6 +682,13 @@ function mapOptionsForApi<TApi extends Api>(
|
||||
});
|
||||
}
|
||||
|
||||
case "ollama-chat":
|
||||
return castApi<"ollama-chat">({
|
||||
...base,
|
||||
reasoning: resolveOpenAiReasoningEffort(model, options),
|
||||
toolChoice: options?.toolChoice,
|
||||
});
|
||||
|
||||
case "cursor-agent": {
|
||||
const execHandlers = options?.cursorExecHandlers ?? options?.execHandlers;
|
||||
const onToolResult = options?.cursorOnToolResult ?? execHandlers?.onToolResult;
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
import type { GoogleOptions } from "./providers/google";
|
||||
import type { GoogleGeminiCliOptions } from "./providers/google-gemini-cli";
|
||||
import type { GoogleVertexOptions } from "./providers/google-vertex";
|
||||
import type { OllamaChatOptions } from "./providers/ollama";
|
||||
import type { OpenAICodexResponsesOptions } from "./providers/openai-codex-responses";
|
||||
import type { OpenAICompletionsOptions } from "./providers/openai-completions";
|
||||
import type { OpenAIResponsesOptions } from "./providers/openai-responses";
|
||||
@@ -40,6 +41,7 @@ export type KnownApi =
|
||||
| "google-generative-ai"
|
||||
| "google-gemini-cli"
|
||||
| "google-vertex"
|
||||
| "ollama-chat"
|
||||
| "cursor-agent";
|
||||
export type Api = KnownApi | (string & {});
|
||||
export interface ApiOptionsMap {
|
||||
@@ -52,6 +54,7 @@ export interface ApiOptionsMap {
|
||||
"google-generative-ai": GoogleOptions;
|
||||
"google-gemini-cli": GoogleGeminiCliOptions;
|
||||
"google-vertex": GoogleVertexOptions;
|
||||
"ollama-chat": OllamaChatOptions;
|
||||
"cursor-agent": CursorOptions;
|
||||
}
|
||||
// Compile-time exhaustiveness check - this will fail if ApiOptionsMap doesn't have all KnownApi keys
|
||||
@@ -120,6 +123,7 @@ export type KnownProvider =
|
||||
| "nvidia"
|
||||
| "nanogpt"
|
||||
| "ollama"
|
||||
| "ollama-cloud"
|
||||
| "qianfan"
|
||||
| "qwen-portal"
|
||||
| "together"
|
||||
|
||||
@@ -91,6 +91,7 @@ export { loginNanoGPT } from "./nanogpt";
|
||||
export { loginNvidia } from "./nvidia";
|
||||
// Ollama (optional API key)
|
||||
export { loginOllama } from "./ollama";
|
||||
export { loginOllamaCloud } from "./ollama-cloud";
|
||||
export type { OpenAICodexLoginOptions } from "./openai-codex";
|
||||
// OpenAI Codex (ChatGPT OAuth)
|
||||
export { loginOpenAICodex, refreshOpenAICodexToken } from "./openai-codex";
|
||||
@@ -200,6 +201,11 @@ const builtInOAuthProviders: OAuthProviderInfo[] = [
|
||||
name: "Ollama (Local OpenAI-compatible)",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: "ollama-cloud",
|
||||
name: "Ollama Cloud",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: "huggingface",
|
||||
name: "Hugging Face Inference",
|
||||
@@ -398,6 +404,7 @@ export async function refreshOAuthToken(
|
||||
case "litellm":
|
||||
case "lm-studio":
|
||||
case "ollama":
|
||||
case "ollama-cloud":
|
||||
case "xiaomi":
|
||||
case "zai":
|
||||
case "qianfan":
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { OAuthController } from "./types";
|
||||
|
||||
const OLLAMA_CLOUD_KEYS_URL = "https://ollama.com/settings/keys";
|
||||
|
||||
export async function loginOllamaCloud(options: OAuthController): Promise<string> {
|
||||
if (options.signal?.aborted) {
|
||||
throw new Error("Login cancelled");
|
||||
}
|
||||
if (!options.onPrompt) {
|
||||
throw new Error("Interactive prompt is required for Ollama Cloud login");
|
||||
}
|
||||
options.onAuth?.({
|
||||
url: OLLAMA_CLOUD_KEYS_URL,
|
||||
instructions: "Create an Ollama Cloud API key, then paste it here.",
|
||||
});
|
||||
const apiKey = await options.onPrompt({
|
||||
message: "Paste your Ollama Cloud API key",
|
||||
placeholder: "ollama-cloud-api-key",
|
||||
});
|
||||
if (options.signal?.aborted) {
|
||||
throw new Error("Login cancelled");
|
||||
}
|
||||
const trimmed = apiKey.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Ollama Cloud API key is required");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export type OAuthProvider =
|
||||
| "nvidia"
|
||||
| "nanogpt"
|
||||
| "ollama"
|
||||
| "ollama-cloud"
|
||||
| "openai-codex"
|
||||
| "opencode-go"
|
||||
| "opencode-zen"
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as path from "node:path";
|
||||
|
||||
import { AuthCredentialStore, AuthStorage } from "../src/auth-storage";
|
||||
import * as kagiModule from "../src/utils/oauth/kagi";
|
||||
import * as ollamaCloudModule from "../src/utils/oauth/ollama-cloud";
|
||||
|
||||
function countCredentialRows(dbPath: string, provider: string): number {
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
@@ -25,6 +26,7 @@ describe("AuthStorage api-key login replacement", () => {
|
||||
let store: AuthCredentialStore | null = null;
|
||||
let authStorage: AuthStorage | null = null;
|
||||
let loginKagiSpy: Mock<typeof kagiModule.loginKagi>;
|
||||
let loginOllamaCloudSpy: Mock<typeof ollamaCloudModule.loginOllamaCloud>;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-ai-auth-api-key-login-"));
|
||||
@@ -32,6 +34,7 @@ describe("AuthStorage api-key login replacement", () => {
|
||||
store = await AuthCredentialStore.open(dbPath);
|
||||
authStorage = new AuthStorage(store);
|
||||
loginKagiSpy = vi.spyOn(kagiModule, "loginKagi");
|
||||
loginOllamaCloudSpy = vi.spyOn(ollamaCloudModule, "loginOllamaCloud");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -71,4 +74,30 @@ describe("AuthStorage api-key login replacement", () => {
|
||||
expect(store.getApiKey("kagi")).toBe("same-kagi-key");
|
||||
expect(await authStorage.getApiKey("kagi", "session-kagi-relogin")).toBe("same-kagi-key");
|
||||
});
|
||||
|
||||
it("reuses the stored api-key row when ollama-cloud re-login returns the same key", async () => {
|
||||
if (!store || !authStorage || !dbPath) throw new Error("test setup failed");
|
||||
|
||||
loginOllamaCloudSpy.mockResolvedValueOnce("same-ollama-cloud-key").mockResolvedValueOnce("same-ollama-cloud-key");
|
||||
|
||||
const controller = {
|
||||
onAuth: () => {},
|
||||
onPrompt: async () => "",
|
||||
};
|
||||
|
||||
await authStorage.login("ollama-cloud", controller);
|
||||
await authStorage.login("ollama-cloud", controller);
|
||||
|
||||
expect(countCredentialRows(dbPath, "ollama-cloud")).toBe(1);
|
||||
const credentials = store.listAuthCredentials("ollama-cloud");
|
||||
expect(credentials).toHaveLength(1);
|
||||
const [stored] = credentials;
|
||||
expect(stored?.credential.type).toBe("api_key");
|
||||
if (!stored || stored.credential.type !== "api_key") {
|
||||
throw new Error("expected stored api-key credential");
|
||||
}
|
||||
expect(stored.credential.key).toBe("same-ollama-cloud-key");
|
||||
expect(store.getApiKey("ollama-cloud")).toBe("same-ollama-cloud-key");
|
||||
expect(await authStorage.getApiKey("ollama-cloud", "session-ollama-cloud-relogin")).toBe("same-ollama-cloud-key");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { loginOllamaCloud } from "../src/utils/oauth/ollama-cloud";
|
||||
|
||||
describe("ollama cloud login", () => {
|
||||
it("opens Ollama Cloud key settings and trims the pasted key", async () => {
|
||||
let authUrl: string | undefined;
|
||||
let authInstructions: string | undefined;
|
||||
let promptMessage: string | undefined;
|
||||
let promptPlaceholder: string | undefined;
|
||||
|
||||
const apiKey = await loginOllamaCloud({
|
||||
onAuth: info => {
|
||||
authUrl = info.url;
|
||||
authInstructions = info.instructions;
|
||||
},
|
||||
onPrompt: async prompt => {
|
||||
promptMessage = prompt.message;
|
||||
promptPlaceholder = prompt.placeholder;
|
||||
return " ollama-cloud-key ";
|
||||
},
|
||||
});
|
||||
|
||||
expect(authUrl).toBe("https://ollama.com/settings/keys");
|
||||
expect(authInstructions).toContain("Create an Ollama Cloud API key");
|
||||
expect(promptMessage).toBe("Paste your Ollama Cloud API key");
|
||||
expect(promptPlaceholder).toBe("ollama-cloud-api-key");
|
||||
expect(apiKey).toBe("ollama-cloud-key");
|
||||
});
|
||||
|
||||
it("rejects empty keys", async () => {
|
||||
await expect(
|
||||
loginOllamaCloud({
|
||||
onPrompt: async () => " ",
|
||||
}),
|
||||
).rejects.toThrow("Ollama Cloud API key is required");
|
||||
});
|
||||
|
||||
it("requires onPrompt callback", async () => {
|
||||
await expect(loginOllamaCloud({})).rejects.toThrow("Interactive prompt is required for Ollama Cloud login");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,487 @@
|
||||
import { afterEach, describe, expect, test, vi } from "bun:test";
|
||||
import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "../src/provider-models/descriptors";
|
||||
import { ollamaCloudModelManagerOptions } from "../src/provider-models/ollama";
|
||||
import { completeSimple, getEnvApiKey, stream, streamSimple } from "../src/stream";
|
||||
import type { Context, Model, Tool } from "../src/types";
|
||||
import { getOAuthProviders } from "../src/utils/oauth";
|
||||
|
||||
const originalApiKey = Bun.env.OLLAMA_CLOUD_API_KEY;
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
const cloudModel: Model<"ollama-chat"> = {
|
||||
id: "gpt-oss:120b",
|
||||
name: "GPT OSS 120B",
|
||||
api: "ollama-chat",
|
||||
provider: "ollama-cloud",
|
||||
baseUrl: "https://ollama.com",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 262_144,
|
||||
maxTokens: 8_192,
|
||||
};
|
||||
|
||||
const readFileTool = {
|
||||
name: "read_file",
|
||||
description: "Read a file from disk",
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["path"],
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
},
|
||||
} as never,
|
||||
} satisfies Tool;
|
||||
|
||||
function createNdjsonResponse(lines: unknown[]): Response {
|
||||
const body = `${lines.map(line => JSON.stringify(line)).join("\n")}\n`;
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(body));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/x-ndjson" },
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (originalApiKey === undefined) {
|
||||
delete Bun.env.OLLAMA_CLOUD_API_KEY;
|
||||
} else {
|
||||
Bun.env.OLLAMA_CLOUD_API_KEY = originalApiKey;
|
||||
}
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ollama-cloud provider support", () => {
|
||||
test("resolves OLLAMA_CLOUD_API_KEY from environment", () => {
|
||||
Bun.env.OLLAMA_CLOUD_API_KEY = "ollama-cloud-test-key";
|
||||
expect(getEnvApiKey("ollama-cloud")).toBe("ollama-cloud-test-key");
|
||||
});
|
||||
|
||||
test("registers built-in descriptor, default model, and oauth selector entry", () => {
|
||||
const descriptor = PROVIDER_DESCRIPTORS.find(item => item.providerId === "ollama-cloud");
|
||||
expect(descriptor).toBeDefined();
|
||||
expect(descriptor?.defaultModel).toBe("gpt-oss:120b");
|
||||
expect(descriptor?.catalogDiscovery?.envVars).toContain("OLLAMA_CLOUD_API_KEY");
|
||||
expect(descriptor?.catalogDiscovery?.allowUnauthenticated).toBeUndefined();
|
||||
expect(DEFAULT_MODEL_PER_PROVIDER["ollama-cloud"]).toBe("gpt-oss:120b");
|
||||
|
||||
const provider = getOAuthProviders().find(item => item.id === "ollama-cloud");
|
||||
expect(provider?.name).toBe("Ollama Cloud");
|
||||
});
|
||||
|
||||
test("discovers ollama-cloud models from native cloud endpoints", async () => {
|
||||
global.fetch = vi.fn(async (input, init) => {
|
||||
const url = String(input);
|
||||
const headers = new Headers(init?.headers);
|
||||
expect(headers.get("Authorization")).toBe("Bearer cloud-test-key");
|
||||
if (url === "https://ollama.com/api/tags") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
models: [{ name: "gpt-oss:120b" }, { model: "qwen3:32b", name: "Qwen 3 32B" }],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (url === "https://ollama.com/api/show") {
|
||||
const body = JSON.parse(String(init?.body ?? "{}")) as { model?: string };
|
||||
if (body.model === "gpt-oss:120b") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
capabilities: ["completion", "thinking", "vision"],
|
||||
model_info: { "gpt-oss.context_length": 262144 },
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response(JSON.stringify({ capabilities: ["completion", "vision"] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const options = ollamaCloudModelManagerOptions({ apiKey: "cloud-test-key" });
|
||||
const models = await options.fetchDynamicModels?.();
|
||||
const gpt = models?.find(model => model.id === "gpt-oss:120b");
|
||||
const qwen = models?.find(model => model.id === "qwen3:32b");
|
||||
|
||||
expect(options.providerId).toBe("ollama-cloud");
|
||||
expect(gpt?.provider).toBe("ollama-cloud");
|
||||
expect(gpt?.api).toBe("ollama-chat");
|
||||
expect(gpt?.baseUrl).toBe("https://ollama.com");
|
||||
expect(gpt?.reasoning).toBe(true);
|
||||
expect(gpt?.contextWindow).toBe(262144);
|
||||
expect(gpt?.input).toEqual(["text", "image"]);
|
||||
expect(qwen?.name).toBe("Qwen 3 32B");
|
||||
expect(qwen?.input).toEqual(["text", "image"]);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://ollama.com/api/tags",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("tolerates individual /api/show failures during model discovery", async () => {
|
||||
global.fetch = vi.fn(async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url === "https://ollama.com/api/tags") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
models: [{ name: "model-a" }, { name: "model-b" }],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (url === "https://ollama.com/api/show") {
|
||||
const body = JSON.parse(String(init?.body ?? "{}")) as { model?: string };
|
||||
if (body.model === "model-b") {
|
||||
throw new Error("network error");
|
||||
}
|
||||
return new Response(JSON.stringify({ capabilities: ["completion"] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const options = ollamaCloudModelManagerOptions({ apiKey: "cloud-test-key" });
|
||||
const models = await options.fetchDynamicModels?.();
|
||||
|
||||
const ids = models?.map(m => m.id).sort();
|
||||
expect(ids).toEqual(["model-a", "model-b"]);
|
||||
const modelB = models?.find(m => m.id === "model-b");
|
||||
expect(modelB?.input).toEqual(["text"]);
|
||||
});
|
||||
|
||||
test("falls back to bundled metadata when /api/show metadata is unavailable", async () => {
|
||||
global.fetch = vi.fn(async (input, _init) => {
|
||||
const url = String(input);
|
||||
if (url === "https://ollama.com/api/tags") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
models: [{ name: "gpt-oss:120b" }],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (url === "https://ollama.com/api/show") {
|
||||
return new Response(null, { status: 500 });
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const options = ollamaCloudModelManagerOptions({ apiKey: "cloud-test-key" });
|
||||
const models = await options.fetchDynamicModels?.();
|
||||
const model = models?.find(candidate => candidate.id === "gpt-oss:120b");
|
||||
|
||||
expect(model).toBeDefined();
|
||||
expect(model?.name).toBe("GPT OSS (120B)");
|
||||
expect(model?.reasoning).toBe(true);
|
||||
expect(model?.input).toEqual(["text", "image"]);
|
||||
expect(model?.contextWindow).toBe(131072);
|
||||
expect(model?.maxTokens).toBe(16384);
|
||||
});
|
||||
|
||||
test("streams native chat responses with thinking, text, and usage mapping", async () => {
|
||||
global.fetch = vi.fn(async (input, init) => {
|
||||
expect(String(input)).toBe("https://ollama.com/api/chat");
|
||||
const headers = new Headers(init?.headers);
|
||||
expect(headers.get("Authorization")).toBe("Bearer cloud-test-key");
|
||||
return createNdjsonResponse([
|
||||
{
|
||||
model: "gpt-oss:120b",
|
||||
message: { role: "assistant", thinking: "Need to think." },
|
||||
done: false,
|
||||
},
|
||||
{
|
||||
model: "gpt-oss:120b",
|
||||
message: { role: "assistant", content: "Hello" },
|
||||
done: false,
|
||||
},
|
||||
{
|
||||
model: "gpt-oss:120b",
|
||||
message: { role: "assistant", content: " world" },
|
||||
done: false,
|
||||
},
|
||||
{
|
||||
model: "gpt-oss:120b",
|
||||
done: true,
|
||||
done_reason: "stop",
|
||||
prompt_eval_count: 11,
|
||||
eval_count: 4,
|
||||
},
|
||||
]);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const response = stream(
|
||||
cloudModel,
|
||||
{
|
||||
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
||||
},
|
||||
{ apiKey: "cloud-test-key" },
|
||||
);
|
||||
|
||||
const eventTypes: string[] = [];
|
||||
for await (const event of response) {
|
||||
eventTypes.push(event.type);
|
||||
}
|
||||
const result = await response.result();
|
||||
|
||||
expect(eventTypes).toContain("thinking_start");
|
||||
expect(eventTypes).toContain("thinking_delta");
|
||||
expect(eventTypes).toContain("text_start");
|
||||
expect(eventTypes).toContain("text_delta");
|
||||
expect(result.stopReason).toBe("stop");
|
||||
expect(result.usage.input).toBe(11);
|
||||
expect(result.usage.output).toBe(4);
|
||||
expect(result.content).toEqual([
|
||||
{ type: "thinking", thinking: "Need to think." },
|
||||
{ type: "text", text: "Hello world" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("supports ollama-cloud through streamSimple option mapping", async () => {
|
||||
global.fetch = vi.fn(async () =>
|
||||
createNdjsonResponse([
|
||||
{
|
||||
model: "gpt-oss:120b",
|
||||
message: { role: "assistant", content: "Mapped through streamSimple" },
|
||||
done: false,
|
||||
},
|
||||
{ model: "gpt-oss:120b", done: true, done_reason: "stop", prompt_eval_count: 2, eval_count: 4 },
|
||||
]),
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
const response = await streamSimple(
|
||||
cloudModel,
|
||||
{ messages: [{ role: "user", content: "Say hi", timestamp: Date.now() }] },
|
||||
{ apiKey: "cloud-test-key", toolChoice: "auto" },
|
||||
).result();
|
||||
|
||||
expect(response.stopReason).toBe("stop");
|
||||
expect(response.content).toEqual([{ type: "text", text: "Mapped through streamSimple" }]);
|
||||
expect(response.usage.input).toBe(2);
|
||||
expect(response.usage.output).toBe(4);
|
||||
});
|
||||
|
||||
test("supports ollama-cloud through completeSimple top-level contract", async () => {
|
||||
global.fetch = vi.fn(async () =>
|
||||
createNdjsonResponse([
|
||||
{
|
||||
model: "gpt-oss:120b",
|
||||
message: { role: "assistant", content: "Completed through completeSimple" },
|
||||
done: false,
|
||||
},
|
||||
{ model: "gpt-oss:120b", done: true, done_reason: "stop", prompt_eval_count: 3, eval_count: 5 },
|
||||
]),
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
const response = await completeSimple(
|
||||
cloudModel,
|
||||
{ messages: [{ role: "user", content: "Finish this", timestamp: Date.now() }] },
|
||||
{ apiKey: "cloud-test-key" },
|
||||
);
|
||||
|
||||
expect(response.stopReason).toBe("stop");
|
||||
expect(response.content).toEqual([{ type: "text", text: "Completed through completeSimple" }]);
|
||||
expect(response.usage.input).toBe(3);
|
||||
expect(response.usage.output).toBe(5);
|
||||
});
|
||||
test("streams tool calls and maps native tool stop reasons", async () => {
|
||||
global.fetch = vi.fn(async () =>
|
||||
createNdjsonResponse([
|
||||
{
|
||||
model: "gpt-oss:120b",
|
||||
message: {
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
index: 0,
|
||||
name: "read_file",
|
||||
arguments: { path: "README.md" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
done: false,
|
||||
},
|
||||
{
|
||||
model: "gpt-oss:120b",
|
||||
done: true,
|
||||
done_reason: "tool_calls",
|
||||
prompt_eval_count: 5,
|
||||
eval_count: 2,
|
||||
},
|
||||
]),
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
const response = stream(
|
||||
cloudModel,
|
||||
{
|
||||
messages: [{ role: "user", content: "Read README", timestamp: Date.now() }],
|
||||
tools: [readFileTool],
|
||||
},
|
||||
{ apiKey: "cloud-test-key" },
|
||||
);
|
||||
const eventTypes: string[] = [];
|
||||
for await (const event of response) {
|
||||
eventTypes.push(event.type);
|
||||
}
|
||||
const result = await response.result();
|
||||
const toolCall = result.content.find(block => block.type === "toolCall");
|
||||
|
||||
expect(eventTypes).toContain("toolcall_start");
|
||||
expect(eventTypes).toContain("toolcall_end");
|
||||
expect(result.stopReason).toBe("toolUse");
|
||||
expect(toolCall && toolCall.type === "toolCall" ? toolCall.name : undefined).toBe("read_file");
|
||||
expect(
|
||||
toolCall && toolCall.type === "toolCall" ? (toolCall.arguments as { path?: string }).path : undefined,
|
||||
).toBe("README.md");
|
||||
});
|
||||
|
||||
test("converts replay history, tools, and images into native ollama chat payloads", async () => {
|
||||
let requestBody: Record<string, unknown> | undefined;
|
||||
global.fetch = vi.fn(async (_input, init) => {
|
||||
requestBody = JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
|
||||
return createNdjsonResponse([
|
||||
{
|
||||
model: "gpt-oss:120b",
|
||||
message: { role: "assistant", content: "done" },
|
||||
done: false,
|
||||
},
|
||||
{ model: "gpt-oss:120b", done: true, done_reason: "stop", prompt_eval_count: 3, eval_count: 1 },
|
||||
]);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const context: Context = {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Inspect this image" },
|
||||
{ type: "image", mimeType: "image/png", data: "aW1hZ2U=" },
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "tool-1", name: "read_file", arguments: { path: "README.md" } }],
|
||||
api: "ollama-chat",
|
||||
provider: "ollama-cloud",
|
||||
model: "gpt-oss:120b",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "read_file",
|
||||
content: [{ type: "text", text: "README contents" }],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
tools: [readFileTool],
|
||||
};
|
||||
|
||||
await stream(cloudModel, context, { apiKey: "cloud-test-key" }).result();
|
||||
|
||||
const messages = requestBody?.messages as Array<Record<string, unknown>> | undefined;
|
||||
expect(requestBody?.model).toBe("gpt-oss:120b");
|
||||
expect(requestBody?.stream).toBe(true);
|
||||
expect(Array.isArray(requestBody?.tools)).toBe(true);
|
||||
expect(messages?.[0]).toMatchObject({
|
||||
role: "user",
|
||||
content: "Inspect this image",
|
||||
images: ["aW1hZ2U="],
|
||||
});
|
||||
expect(messages?.[1]).toMatchObject({
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{
|
||||
type: "function",
|
||||
function: { name: "read_file", arguments: { path: "README.md" } },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(messages?.[2]).toMatchObject({
|
||||
role: "tool",
|
||||
tool_name: "read_file",
|
||||
content: "README contents",
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapToolChoice", () => {
|
||||
test("omits tool_choice when undefined or auto", async () => {
|
||||
let requestBody: Record<string, unknown> | undefined;
|
||||
global.fetch = vi.fn(async (_input, init) => {
|
||||
requestBody = JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
|
||||
return createNdjsonResponse([
|
||||
{ model: "gpt-oss:120b", message: { role: "assistant", content: "ok" }, done: false },
|
||||
{ model: "gpt-oss:120b", done: true, done_reason: "stop", prompt_eval_count: 1, eval_count: 1 },
|
||||
]);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await stream(
|
||||
cloudModel,
|
||||
{ messages: [{ role: "user", content: "hi", timestamp: Date.now() }], tools: [readFileTool] },
|
||||
{ apiKey: "cloud-test-key", toolChoice: "auto" },
|
||||
).result();
|
||||
expect(requestBody?.tool_choice).toBeUndefined();
|
||||
});
|
||||
|
||||
test("passes tool_choice: none when ToolChoice is none", async () => {
|
||||
let requestBody: Record<string, unknown> | undefined;
|
||||
global.fetch = vi.fn(async (_input, init) => {
|
||||
requestBody = JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
|
||||
return createNdjsonResponse([
|
||||
{ model: "gpt-oss:120b", message: { role: "assistant", content: "ok" }, done: false },
|
||||
{ model: "gpt-oss:120b", done: true, done_reason: "stop", prompt_eval_count: 1, eval_count: 1 },
|
||||
]);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await stream(
|
||||
cloudModel,
|
||||
{ messages: [{ role: "user", content: "hi", timestamp: Date.now() }], tools: [readFileTool] },
|
||||
{ apiKey: "cloud-test-key", toolChoice: "none" },
|
||||
).result();
|
||||
expect(requestBody?.tool_choice).toBe("none");
|
||||
});
|
||||
|
||||
test("passes tool_choice: required when ToolChoice is required or any", async () => {
|
||||
let requestBody: Record<string, unknown> | undefined;
|
||||
global.fetch = vi.fn(async (_input, init) => {
|
||||
requestBody = JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
|
||||
return createNdjsonResponse([
|
||||
{ model: "gpt-oss:120b", message: { role: "assistant", content: "ok" }, done: false },
|
||||
{ model: "gpt-oss:120b", done: true, done_reason: "stop", prompt_eval_count: 1, eval_count: 1 },
|
||||
]);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await stream(
|
||||
cloudModel,
|
||||
{ messages: [{ role: "user", content: "hi", timestamp: Date.now() }], tools: [readFileTool] },
|
||||
{ apiKey: "cloud-test-key", toolChoice: "required" },
|
||||
).result();
|
||||
expect(requestBody?.tool_choice).toBe("required");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,12 @@ export function buildNamedToolChoice(toolName: string, model?: Model<Api>): Tool
|
||||
return { type: "function", name: toolName };
|
||||
}
|
||||
|
||||
if (model.api === "google-generative-ai" || model.api === "google-gemini-cli" || model.api === "google-vertex") {
|
||||
if (
|
||||
model.api === "google-generative-ai" ||
|
||||
model.api === "google-gemini-cli" ||
|
||||
model.api === "google-vertex" ||
|
||||
model.api === "ollama-chat"
|
||||
) {
|
||||
return "required";
|
||||
}
|
||||
|
||||
|
||||
@@ -1417,6 +1417,68 @@ describe("ModelRegistry", () => {
|
||||
expect(await registry.getApiKey(ollamaModels[0])).toBe(kNoAuth);
|
||||
});
|
||||
|
||||
test("discovers ollama-cloud through built-in descriptor flow without regressing local implicit ollama", async () => {
|
||||
authStorage.setRuntimeApiKey("ollama-cloud", "cloud-test-key");
|
||||
|
||||
using _hook = hookFetch((input, init) => {
|
||||
const url = String(input);
|
||||
if (url === "http://127.0.0.1:11434/api/tags") {
|
||||
return new Response(JSON.stringify({ models: [{ name: "phi4-mini" }] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url === "http://127.0.0.1:11434/api/show") {
|
||||
return new Response(JSON.stringify({ capabilities: ["completion"] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url === "https://ollama.com/api/tags") {
|
||||
const headers = new Headers(init?.headers);
|
||||
expect(headers.get("Authorization")).toBe("Bearer cloud-test-key");
|
||||
return new Response(JSON.stringify({ models: [{ name: "gpt-oss:120b" }] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url === "https://ollama.com/api/show") {
|
||||
const headers = new Headers(init?.headers);
|
||||
expect(headers.get("Authorization")).toBe("Bearer cloud-test-key");
|
||||
const body = JSON.parse(String(init?.body ?? "{}")) as { model?: string };
|
||||
expect(body.model).toBe("gpt-oss:120b");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
capabilities: ["completion", "thinking"],
|
||||
model_info: { "gpt-oss.context_length": 262144 },
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
await registry.refresh();
|
||||
|
||||
const local = registry.find("ollama", "phi4-mini");
|
||||
const cloud = registry.find("ollama-cloud", "gpt-oss:120b");
|
||||
|
||||
expect(local?.provider).toBe("ollama");
|
||||
expect(local?.api).toBe("openai-responses");
|
||||
expect(cloud?.provider).toBe("ollama-cloud");
|
||||
expect(cloud?.api).toBe("ollama-chat");
|
||||
expect(cloud?.baseUrl).toBe("https://ollama.com");
|
||||
expect(cloud?.reasoning).toBe(true);
|
||||
expect(cloud?.contextWindow).toBe(262144);
|
||||
expect(await registry.getApiKey(cloud!)).toBe("cloud-test-key");
|
||||
expect(registry.getAvailable().some(model => model.provider === "ollama" && model.id === "phi4-mini")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
registry.getAvailable().some(model => model.provider === "ollama-cloud" && model.id === "gpt-oss:120b"),
|
||||
).toBe(true);
|
||||
});
|
||||
test("discovers ollama models at runtime and treats auth:none providers as available", async () => {
|
||||
writeRawModelsJson({
|
||||
ollama: {
|
||||
|
||||
Reference in New Issue
Block a user