diff --git a/README.md b/README.md index 505c70c3..784f4700 100644 --- a/README.md +++ b/README.md @@ -53,13 +53,15 @@ Linux installs require kernel 5.1 or newer. Glibc builds require glibc 2.17 or n ## Quickstart -Set an API key from any provider (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, and so on) and start the workspace: +Set an API key from any provider (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `DEEPSEEK_API_KEY`, `GEMINI_API_KEY`, and so on) and start the workspace: ```bash export ANTHROPIC_API_KEY=sk-ant-... openscience ``` +DeepSeek's official API (`https://api.deepseek.com`) is supported natively — set `DEEPSEEK_API_KEY` (or `openscience keys add deepseek`) and pick a `deepseek/*` model. To point at a custom endpoint, set `DEEPSEEK_BASE_URL`. + `openscience` opens the workspace in your browser. Your keys stay on your machine and requests go straight to the provider. You can also run `openscience keys add` to store a key from the terminal, add keys from the Credentials panel, and pick a model from the model selector. To open the workspace in a specific project: ```bash diff --git a/backend/cli/package.json b/backend/cli/package.json index e11ddf14..a4ed3bd5 100644 --- a/backend/cli/package.json +++ b/backend/cli/package.json @@ -57,6 +57,7 @@ "@ai-sdk/cerebras": "1.0.34", "@ai-sdk/cohere": "2.0.22", "@ai-sdk/deepinfra": "1.0.31", + "@ai-sdk/deepseek": "1.0.50", "@ai-sdk/gateway": "2.0.25", "@ai-sdk/google": "2.0.52", "@ai-sdk/google-vertex": "3.0.97", diff --git a/backend/cli/src/provider/provider.ts b/backend/cli/src/provider/provider.ts index 9c81bb7b..c9e848cd 100644 --- a/backend/cli/src/provider/provider.ts +++ b/backend/cli/src/provider/provider.ts @@ -26,6 +26,7 @@ import { createVertex } from "@ai-sdk/google-vertex" import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic" import { createOpenAI } from "@ai-sdk/openai" import { createOpenAICompatible } from "@ai-sdk/openai-compatible" +import { createDeepSeek } from "@ai-sdk/deepseek" import { createOpenRouter, type LanguageModelV2 } from "@openrouter/ai-sdk-provider" import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/openai-compatible/src" import { createXai } from "@ai-sdk/xai" @@ -101,6 +102,7 @@ export namespace Provider { "@ai-sdk/google-vertex/anthropic": createVertexAnthropic, "@ai-sdk/openai": createOpenAI, "@ai-sdk/openai-compatible": createOpenAICompatible, + "@ai-sdk/deepseek": createDeepSeek, "@openrouter/ai-sdk-provider": createOpenRouter, "@ai-sdk/xai": createXai, "@ai-sdk/mistral": createMistral, @@ -653,6 +655,24 @@ export namespace Provider { // Neither an own key nor a managed route — nothing to route with. return { autoload: false, options: { headers } } }, + deepseek: async () => { + // DeepSeek is BYOK-only through the official API — there is no Atlas + // managed route. Resolve the user's own key (auth.json first, then + // DEEPSEEK_API_KEY) and pin to the public endpoint unless an explicit + // DEEPSEEK_BASE_URL points somewhere else. The native @ai-sdk/deepseek + // adapter is selected in the npm-resolution chains below, so the loader + // only supplies the credential + base URL. + const auth = await Auth.get("deepseek").catch(() => undefined) + const authKey = auth?.type === "api" ? auth.key : undefined + const envKey = Env.get("DEEPSEEK_API_KEY") + const apiKey = isByokKey(authKey) ? authKey : isByokKey(envKey) ? envKey : undefined + if (apiKey) { + const envBase = Env.get("DEEPSEEK_BASE_URL") + const baseURL = envBase && !hasManagedProxyPath(envBase) ? envBase : "https://api.deepseek.com" + return { autoload: false, options: { apiKey, baseURL } } + } + return { autoload: false } + }, meta: async () => { // Meta is BYOK-only in the client. Managed Muse Spark now routes through // OpenRouter's `meta/muse-spark-1.1` slug, so stale Atlas Meta proxy env @@ -1155,7 +1175,13 @@ export namespace Provider { api: { id: model.id, url: provider.api!, - npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible", + // DeepSeek's official API is OpenAI-compatible, but the catalog still + // lists it under the generic openai-compatible adapter. Resolve the + // native @ai-sdk/deepseek adapter instead so V4's thinking/tool modes + // and strict-schema validation behave correctly. + npm: + model.provider?.npm ?? + (provider.id === "deepseek" ? "@ai-sdk/deepseek" : (provider.npm ?? "@ai-sdk/openai-compatible")), }, status: model.status ?? "active", headers: model.headers ?? {}, @@ -1351,10 +1377,15 @@ export namespace Provider { id: model.id ?? existingModel?.api.id ?? modelID, npm: model.provider?.npm ?? - provider.npm ?? - existingModel?.api.npm ?? - modelsDev[providerID]?.npm ?? - "@ai-sdk/openai-compatible", + // A config-registered `deepseek` provider (e.g. from `openscience + // local add`) may carry `npm: "@ai-sdk/openai-compatible"`. Prefer + // the native adapter for the deepseek provider ID regardless. + (providerID === "deepseek" + ? "@ai-sdk/deepseek" + : (provider.npm ?? + existingModel?.api.npm ?? + modelsDev[providerID]?.npm ?? + "@ai-sdk/openai-compatible")), url: baseURL ?? provider.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api, }, status: model.status ?? existingModel?.status ?? "active", @@ -1717,6 +1748,26 @@ export namespace Provider { } } + /** Direct-provider-beats-OpenRouter: given an OpenRouter slug like + * `deepseek/deepseek-v4-flash-0731`, find the same vendor's direct BYOK + * provider (official API, no relay). Only returns a route when the direct + * provider actually has a user-owned key — an unauthenticated catalog entry + * must not shadow a working OpenRouter route. Tolerates vendor date suffixes + * (-0731) so it works without knowing the catalog's current snapshot. */ + function resolveDirectVendor(s: Awaited>, openrouterModelID: string) { + const [vendor, ...rest] = openrouterModelID.split("/") + if (!vendor || rest.length === 0) return undefined + const directProviderID = OPENROUTER_VENDOR_PREFIX[vendor] ?? vendor + const provider = s.providers[directProviderID] + if (!provider || !isByokKey(effectiveKey(provider))) return undefined + const directModelID = rest.join("/") + const candidates = [directModelID, directModelID.replace(/-\d{4,}$/, "")] + for (const candidate of candidates) { + if (provider.models[candidate]) return { providerID: directProviderID, modelID: candidate } + } + return undefined + } + function resolveAvailableModel(s: Awaited>, providerID: string, modelID: string) { const exact = s.providers[providerID]?.models[modelID] return exact ?? resolveOpenRouterAlias(s, providerID, modelID) @@ -1995,6 +2046,11 @@ export namespace Provider { if (cfg.small_model) { const parsed = parseModel(cfg.small_model) + const s = await state() + if (parsed.providerID === "openrouter") { + const direct = resolveDirectVendor(s, parsed.modelID) + if (direct) return getModel(direct.providerID, direct.modelID) + } return getModel(parsed.providerID, parsed.modelID) } @@ -2064,8 +2120,18 @@ export namespace Provider { // (e.g. a saved `anthropic/...` model with no API key must not be returned) // — otherwise fall through to the priority-based selection below. const parsed = parseModel(cfg.model) - const resolved = resolveAvailableModel(await state(), parsed.providerID, parsed.modelID) - if (resolved) return { providerID: resolved.providerID, modelID: resolved.id } + const s = await state() + const resolved = resolveAvailableModel(s, parsed.providerID, parsed.modelID) + if (resolved) { + // Direct-provider-beats-OpenRouter: when the configured model is an + // OpenRouter slug for a vendor that also has a direct BYOK provider + // with the same model, prefer the direct route (official API, no relay). + if (parsed.providerID === "openrouter") { + const direct = resolveDirectVendor(s, parsed.modelID) + if (direct) return direct + } + return { providerID: resolved.providerID, modelID: resolved.id } + } log.warn("configured model is not available, falling back to default selection", parsed) } diff --git a/backend/cli/src/provider/tool-schema.ts b/backend/cli/src/provider/tool-schema.ts new file mode 100644 index 00000000..2ab817eb --- /dev/null +++ b/backend/cli/src/provider/tool-schema.ts @@ -0,0 +1,99 @@ +import type { JSONSchema } from "zod/v4/core" + +/** + * Tool-schema normalization for providers that run strict JSON-Schema + * validation on function definitions (DeepSeek's official API and + * OpenAI-compatible gateways in general). + * + * zod discriminated unions serialize to `oneOf`/`anyOf` with no top-level + * `type: "object"`; strict validators reject that shape with a 400 (DeepSeek: + * `schema must be a JSON Schema of 'type: "object"', got 'type: null'`). The + * same validators reject a subset of constraint keywords. We merge the union + * back into a single object schema and drop the rejected keywords so the model + * only needs to produce a structurally valid object — the Zod schema backing + * each tool still enforces full constraints when its arguments are parsed at + * execution time. + * + * Pure and lossless in the direction that matters: every value that satisfied + * the original schema still satisfies the normalized one. + */ + +/** Keywords DeepSeek's strict tool-schema validation rejects. `enum` and + * `anyOf` for VALUES are kept — only these structural/constraint keywords are + * dropped. */ +const STRIPPED_KEYWORDS = new Set([ + "minLength", + "maxLength", + "pattern", + "format", + "minimum", + "maximum", + "multipleOf", + "patternProperties", + "minItems", + "maxItems", +]) + +export function normalizeToolSchema(schema: JSONSchema.BaseSchema): JSONSchema.BaseSchema { + return normalize(schema) +} + +function normalize(node: any): any { + if (node === null || typeof node !== "object") return node + if (Array.isArray(node)) return node.map(normalize) + + const out: any = {} + const unionMembers: any[] = [] + + for (const [key, value] of Object.entries(node)) { + // Root union (zod discriminated union). Flattened below into the object. + if (key === "oneOf" || key === "anyOf" || key === "allOf") { + if (Array.isArray(value)) unionMembers.push(...value) + continue + } + if (STRIPPED_KEYWORDS.has(key)) continue + out[key] = typeof value === "object" && value !== null ? normalize(value) : value + } + + // Merge union members into this object so the result is a single + // `type: "object"` schema. The discriminator's distinct const values widen + // into an enum; `required` intersects across branches (a valid object must + // satisfy one branch, so only fields every branch demands are required). + for (const rawMember of unionMembers) { + const member = normalize(rawMember) + if (!member || typeof member !== "object") continue + if (member.properties && typeof member.properties === "object") { + out.properties = out.properties ?? {} + for (const [key, value] of Object.entries(member.properties)) { + out.properties[key] = mergeProperty(out.properties[key], value) + } + } + if (Array.isArray(member.required)) { + if (!Array.isArray(out.required)) out.required = [...member.required] + else out.required = out.required.filter((field: string) => member.required.includes(field)) + } + } + + if (out.properties) out.type = "object" + return out +} + +/** Merge two definitions of the same property across union branches. Identical + * definitions pass through; distinct literal constraints (the discriminated + * union's discriminator) widen into an enum; anything else allows either + * shape via `anyOf`. */ +function mergeProperty(existing: any, value: any): any { + if (existing === undefined) return value + if (JSON.stringify(existing) === JSON.stringify(value)) return existing + + const aValues = existing.const !== undefined ? [existing.const] : existing.enum + const bValues = value.const !== undefined ? [value.const] : value.enum + if (aValues && bValues) { + const merged: any = { ...existing, ...value } + delete merged.const + merged.enum = [...new Set([...aValues, ...bValues])] + return merged + } + + return { anyOf: [existing, value] } +} diff --git a/backend/cli/src/provider/transform.ts b/backend/cli/src/provider/transform.ts index b1b8e083..8e80411c 100644 --- a/backend/cli/src/provider/transform.ts +++ b/backend/cli/src/provider/transform.ts @@ -3,6 +3,7 @@ import { mergeDeep, unique } from "remeda" import type { JSONSchema } from "zod/v4/core" import type { Provider } from "./provider" import type { ModelsDev } from "./models" +import { normalizeToolSchema } from "./tool-schema" import { iife } from "@/util/iife" type Modality = NonNullable["input"][number] @@ -611,6 +612,20 @@ export namespace ProviderTransform { // https://v5.ai-sdk.dev/providers/ai-sdk-providers/xai case "@ai-sdk/deepinfra": // https://v5.ai-sdk.dev/providers/ai-sdk-providers/deepinfra + case "@ai-sdk/deepseek": + // https://v5.ai-sdk.dev/providers/ai-sdk-providers/deepseek + // The native adapter maps `reasoningEffort` -> body `reasoning_effort` + // and accepts a `max` tier above high (matches the OpenRouter branch). + if (exact) { + return Object.fromEntries(exact.map((effort) => [effort, { reasoningEffort: effort }])) + } + if (/deepseek-v[4-9]/.test(id)) { + return Object.fromEntries( + [...WIDELY_SUPPORTED_EFFORTS, "max"].map((effort) => [effort, { reasoningEffort: effort }]), + ) + } + return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) + case "@ai-sdk/openai-compatible": if (exact) { return Object.fromEntries(exact.map((effort) => [effort, { reasoningEffort: effort }])) @@ -910,6 +925,19 @@ export namespace ProviderTransform { } } + // DeepSeek V4 ships with thinking ON by default. The native adapter replays + // reasoning_content across tool-call turns, so reasoning stays usable — but + // a model configured as non-reasoning (`reasoning: false`) must have + // thinking explicitly disabled, otherwise every agent call silently reasons + // (extra latency/cost) even though no effort variant is offered. + if ( + input.model.api.npm === "@ai-sdk/deepseek" && + /deepseek-v[4-9]/.test(input.model.api.id.toLowerCase()) && + !input.model.capabilities.reasoning + ) { + result["thinking"] = { type: "disabled" } + } + if (input.model.providerID === "openai" || input.providerOptions?.setCacheKey) { result["promptCacheKey"] = input.sessionID } @@ -1041,6 +1069,11 @@ export namespace ProviderTransform { } return { thinkingConfig: { thinkingBudget: 0 } } } + // DeepSeek-v4's default is thinking-on; small calls (titles, summaries, + // compaction) don't need it and the flash model should stay fast. + if (model.api.npm === "@ai-sdk/deepseek" && /deepseek-v[4-9]/.test(apiID)) { + return { thinking: { type: "disabled" } } + } return {} } @@ -1142,6 +1175,14 @@ export namespace ProviderTransform { schema = sanitizeGemini(schema) } + // DeepSeek and generic OpenAI-compatible gateways run strict tool-schema + // validation and reject zod discriminated-union shapes (`type: null`) plus + // several constraint keywords. Normalize the schema at the provider + // boundary so tool definitions always carry a plain `type: "object"`. + if (model.api.npm?.includes("@ai-sdk/deepseek") || model.api.npm?.includes("@ai-sdk/openai-compatible")) { + schema = normalizeToolSchema(schema) + } + return schema } diff --git a/backend/cli/src/tool/compute-job.ts b/backend/cli/src/tool/compute-job.ts index d9ce6423..52aadd49 100644 --- a/backend/cli/src/tool/compute-job.ts +++ b/backend/cli/src/tool/compute-job.ts @@ -2,23 +2,19 @@ import z from "zod" import { ComputeJobs } from "@/compute/jobs" import { Tool } from "./tool" -export const ComputeJobParameters = z.discriminatedUnion("action", [ - z.object({ - action: z.literal("list"), - status: ComputeJobs.Status.optional(), - limit: z.number().int().min(1).max(100).default(20), - }), - z.object({ action: z.literal("status"), job_id: z.string().trim().min(1) }), - z.object({ - action: z.literal("logs"), - job_id: z.string().trim().min(1), - bytes: z.number().int().min(1).max(256_000).default(64_000), - }), - z.object({ action: z.literal("artifacts"), job_id: z.string().trim().min(1) }), - z.object({ action: z.literal("cancel"), job_id: z.string().trim().min(1) }), - z.object({ action: z.literal("retry_delivery"), job_id: z.string().trim().min(1) }), - z.object({ action: z.literal("release"), job_id: z.string().trim().min(1) }), -]) +// Flat object schema instead of a discriminated union. DeepSeek's strict +// tool-schema validation rejects discriminated-union shapes (JSON Schema +// `oneOf` with no top-level `type: "object"` → HTTP 400 `type: null`), so keep +// the tool's contract as a plain `type: "object"`. `job_id` is optional at the +// schema level and required at runtime for every non-list action (see +// execute) — the model only needs to produce one valid object. +export const ComputeJobParameters = z.object({ + action: z.enum(["list", "status", "logs", "artifacts", "cancel", "retry_delivery", "release"]), + status: ComputeJobs.Status.optional(), + limit: z.number().int().min(1).max(100).optional(), + job_id: z.string().trim().min(1).optional(), + bytes: z.number().int().min(1).max(256_000).optional(), +}) type Input = z.infer type Metadata = { @@ -97,7 +93,7 @@ export function createComputeJobTool(base?: ComputeJobs.Options) { if (input.action === "list") { const state = await jobs(base) const filtered = input.status ? state.jobs.filter((job) => job.status === input.status) : state.jobs - const output = filtered.slice(0, input.limit).map(summary) + const output = filtered.slice(0, input.limit ?? 20).map(summary) return { title: "Compute jobs", metadata: { compute_job: { action: input.action, count: output.length } }, @@ -105,6 +101,9 @@ export function createComputeJobTool(base?: ComputeJobs.Options) { } } + if (!input.job_id) { + throw new Error(`The compute_job "${input.action}" action requires a job_id argument.`) + } const state = await selected(input.job_id, base) if (input.action === "status") { return { @@ -114,9 +113,10 @@ export function createComputeJobTool(base?: ComputeJobs.Options) { } } if (input.action === "logs") { + const bytes = input.bytes ?? 64_000 const [events, output] = await Promise.all([ - ComputeJobs.events(state.job.id, { ...state.resolved, bytes: input.bytes }), - ComputeJobs.log(state.job.id, { ...state.resolved, bytes: input.bytes }), + ComputeJobs.events(state.job.id, { ...state.resolved, bytes }), + ComputeJobs.log(state.job.id, { ...state.resolved, bytes }), ]) return { title: `Compute logs: ${state.job.name}`, diff --git a/backend/cli/test/provider/deepseek.test.ts b/backend/cli/test/provider/deepseek.test.ts new file mode 100644 index 00000000..2eecccd9 --- /dev/null +++ b/backend/cli/test/provider/deepseek.test.ts @@ -0,0 +1,171 @@ +import { test, expect, mock } from "bun:test" +import path from "path" + +// === Mocks === +// These mocks prevent real package installations during tests + +mock.module("../../src/bun/index", () => ({ + BunProc: { + install: async (pkg: string, _version?: string) => { + // Return package name without version for mocking + const lastAtIndex = pkg.lastIndexOf("@") + return lastAtIndex > 0 ? pkg.substring(0, lastAtIndex) : pkg + }, + run: async () => { + throw new Error("BunProc.run should not be called in tests") + }, + which: () => process.execPath, + InstallFailedError: class extends Error {}, + }, +})) + +const mockPlugin = () => ({}) +mock.module("openscience-copilot-auth", () => ({ default: mockPlugin })) +mock.module("openscience-anthropic-auth", () => ({ default: mockPlugin })) +mock.module("@gitlab/openscience-gitlab-auth", () => ({ default: mockPlugin })) + +// Import after mocks are set up +const { tmpdir } = await import("../fixture/fixture") +const { Instance } = await import("../../src/project/instance") +const { Provider } = await import("../../src/provider/provider") +const { Env } = await import("../../src/env") + +function writeConfig(dir: string, extra: Record) { + return Bun.write( + path.join(dir, "openscience.json"), + JSON.stringify({ + $schema: "https://syntheticsciences.ai/config.json", + ...extra, + }), + ) +} + +/** Exercise resolution inside a project instance whose env is reset first. */ +function within(directory: string, init: () => Promise, fn: () => Promise) { + return Instance.provide({ + directory, + init: async () => { + Env.set("DEEPSEEK_API_KEY", "") + Env.set("DEEPSEEK_BASE_URL", "") + Env.set("OPENROUTER_API_KEY", "") + await init() + }, + fn, + }) +} + +test("deepseek provider resolves the native @ai-sdk/deepseek adapter", async () => { + await using tmp = await tmpdir({ init: (dir) => writeConfig(dir, {}) }) + await within( + tmp.path, + async () => Env.set("DEEPSEEK_API_KEY", "sk-test-deepseek"), + async () => { + const providers = await Provider.list() + const deepseek = providers["deepseek"] + expect(deepseek).toBeDefined() + // Native adapter wins over the catalog's generic openai-compatible entry. + expect(deepseek.models["deepseek-v4-flash"].api.npm).toBe("@ai-sdk/deepseek") + expect(deepseek.models["deepseek-v4-flash"].api.url).toBe("https://api.deepseek.com") + // The loader pins the public endpoint when no DEEPSEEK_BASE_URL is set. + expect(deepseek.options.baseURL).toBe("https://api.deepseek.com") + }, + ) +}) + +test("deepseek provider honors DEEPSEEK_BASE_URL", async () => { + await using tmp = await tmpdir({ init: (dir) => writeConfig(dir, {}) }) + await within( + tmp.path, + async () => { + Env.set("DEEPSEEK_API_KEY", "sk-test-deepseek") + Env.set("DEEPSEEK_BASE_URL", "https://deepseek.example.internal") + }, + async () => { + const providers = await Provider.list() + // The catalog keeps api.url fixed at the official endpoint; the loader + // applies DEEPSEEK_BASE_URL through the provider options instead. + expect(providers["deepseek"].options.baseURL).toBe("https://deepseek.example.internal") + }, + ) +}) + +test("configured deepseek model resolves through the direct provider", async () => { + await using tmp = await tmpdir({ + init: (dir) => writeConfig(dir, { model: "deepseek/deepseek-v4-flash" }), + }) + await within( + tmp.path, + async () => Env.set("DEEPSEEK_API_KEY", "sk-test-deepseek"), + async () => { + const model = await Provider.defaultModel() + expect(model).toEqual({ providerID: "deepseek", modelID: "deepseek-v4-flash" }) + }, + ) +}) + +test("an OpenRouter deepseek slug prefers the direct BYOK provider when its key is present", async () => { + await using tmp = await tmpdir({ + init: (dir) => writeConfig(dir, { model: "openrouter/deepseek/deepseek-v4-flash" }), + }) + await within( + tmp.path, + async () => { + Env.set("OPENROUTER_API_KEY", "sk-or-test") + Env.set("DEEPSEEK_API_KEY", "sk-test-deepseek") + }, + async () => { + const model = await Provider.defaultModel() + expect(model).toEqual({ providerID: "deepseek", modelID: "deepseek-v4-flash" }) + }, + ) +}) + +test("an OpenRouter deepseek slug stays on OpenRouter when no direct key is present", async () => { + await using tmp = await tmpdir({ + init: (dir) => writeConfig(dir, { model: "openrouter/deepseek/deepseek-v4-flash" }), + }) + await within( + tmp.path, + async () => Env.set("OPENROUTER_API_KEY", "sk-or-test"), + async () => { + const model = await Provider.defaultModel() + expect(model).toEqual({ providerID: "openrouter", modelID: "deepseek/deepseek-v4-flash" }) + }, + ) +}) + +test("a configured small_model OpenRouter slug routes to the direct deepseek provider", async () => { + await using tmp = await tmpdir({ + init: (dir) => writeConfig(dir, { small_model: "openrouter/deepseek/deepseek-v4-flash" }), + }) + await within( + tmp.path, + async () => { + Env.set("OPENROUTER_API_KEY", "sk-or-test") + Env.set("DEEPSEEK_API_KEY", "sk-test-deepseek") + }, + async () => { + const model = await Provider.getSmallModel("deepseek") + expect(model?.providerID).toBe("deepseek") + expect(model?.id).toBe("deepseek-v4-flash") + }, + ) +}) + +test("an unauthenticated catalog deepseek entry never shadows a working OpenRouter route", async () => { + await using tmp = await tmpdir({ + init: (dir) => writeConfig(dir, { model: "openrouter/deepseek/deepseek-v4-flash" }), + }) + await within( + tmp.path, + async () => Env.set("OPENROUTER_API_KEY", "sk-or-test"), + async () => { + // No DEEPSEEK_API_KEY, no config deepseek block — the direct provider must + // not exist, so resolution stays on OpenRouter. + const providers = await Provider.list() + expect(providers["deepseek"]).toBeUndefined() + const model = await Provider.defaultModel() + expect(model).toEqual({ providerID: "openrouter", modelID: "deepseek/deepseek-v4-flash" }) + }, + ) +}) diff --git a/backend/cli/test/provider/tool-schema.test.ts b/backend/cli/test/provider/tool-schema.test.ts new file mode 100644 index 00000000..2582ba0b --- /dev/null +++ b/backend/cli/test/provider/tool-schema.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from "bun:test" +import { normalizeToolSchema } from "../../src/provider/tool-schema" +import { ProviderTransform } from "../../src/provider/transform" + +/** A zod discriminated-union serialization exactly as z.toJSONSchema emits it + * (see the compute_job tool pre-fix): root-level `oneOf`, per-branch `const` + * discriminators, constraint keywords on properties. */ +const discriminatedUnion = { + $schema: "https://json-schema.org/draft/2020-12/schema", + oneOf: [ + { + type: "object", + properties: { + action: { type: "string", const: "list" }, + status: { type: "string", enum: ["running", "succeeded", "failed"] }, + limit: { type: "integer", minimum: 1, maximum: 100 }, + }, + required: ["action"], + additionalProperties: false, + }, + { + type: "object", + properties: { + action: { type: "string", const: "status" }, + job_id: { type: "string", minLength: 1 }, + }, + required: ["action", "job_id"], + additionalProperties: false, + }, + ], +} + +const norm = (schema: any): any => normalizeToolSchema(schema) + +const model = (npm: string) => + ({ + id: "deepseek/deepseek-v4-flash", + providerID: "deepseek", + api: { id: "deepseek-v4-flash", url: "https://api.deepseek.com", npm }, + name: "deepseek-v4-flash", + capabilities: { temperature: true, reasoning: true, toolcall: true }, + status: "active", + options: {}, + headers: {}, + }) as any + +describe("normalizeToolSchema", () => { + test("flattens a root discriminated union into a single object schema", () => { + const out = norm(discriminatedUnion) + expect(out.type).toBe("object") + expect(out.oneOf).toBeUndefined() + expect(out.anyOf).toBeUndefined() + expect(out.properties).toBeDefined() + }) + + test("widens the const discriminator into an enum", () => { + const out = norm(discriminatedUnion) + expect(out.properties.action).toEqual({ type: "string", enum: ["list", "status"] }) + expect(out.properties.action.const).toBeUndefined() + }) + + test("intersects required across union branches", () => { + const out = norm(discriminatedUnion) + expect(out.required).toEqual(["action"]) + }) + + test("strips constraint keywords DeepSeek rejects but keeps enum/type", () => { + const out = norm(discriminatedUnion) + expect(out.properties.limit).toEqual({ type: "integer" }) + expect(out.properties.status).toEqual({ type: "string", enum: ["running", "succeeded", "failed"] }) + }) + + test("drops minLength/maxLength/pattern/format recursively", () => { + const input = { + type: "object", + properties: { + name: { type: "string", minLength: 1, maxLength: 100, pattern: "^[a-z]+$", format: "email" }, + nested: { type: "object", properties: { score: { type: "number", minimum: 0, maximum: 1, multipleOf: 0.1 } } }, + tags: { type: "array", minItems: 1, maxItems: 5 }, + patternMap: { type: "object", patternProperties: { "^x": { type: "string" } } }, + }, + } + const out = norm(input) + expect(out.properties.name).toEqual({ type: "string" }) + expect(out.properties.nested.properties.score).toEqual({ type: "number" }) + expect(out.properties.tags).toEqual({ type: "array" }) + expect(out.properties.patternMap).toEqual({ type: "object" }) + }) + + test("keeps defaults and non-rejected keywords", () => { + const input = { + type: "object", + properties: { action: { type: "string", default: "list", description: "which job action" } }, + } + const out = norm(input) + expect(out.properties.action).toEqual({ type: "string", default: "list", description: "which job action" }) + }) + + test("passes through a plain object schema unchanged", () => { + const input = { type: "object", properties: { a: { type: "string" } }, required: ["a"] } + expect(norm(input)).toEqual(input) + }) + + test("every value valid under the original union is valid under the normalized schema", () => { + const out = norm(discriminatedUnion) + for (const valid of [ + { action: "list", limit: 20 }, + { action: "status", job_id: "job-1" }, + ]) { + // Structural check only — the union's branches accepted both shapes. + expect(out.properties.action.enum).toContain(valid.action) + for (const key of Object.keys(valid)) { + expect(out.properties[key]).toBeDefined() + } + } + }) +}) + +describe("ProviderTransform.schema", () => { + test("normalizes tool schemas for the native deepseek adapter", () => { + const out: any = ProviderTransform.schema(model("@ai-sdk/deepseek"), discriminatedUnion as any) + expect(out.type).toBe("object") + expect(out.oneOf).toBeUndefined() + expect(out.properties.action.enum).toEqual(["list", "status"]) + }) + + test("normalizes tool schemas for openai-compatible gateways", () => { + const out: any = ProviderTransform.schema(model("@ai-sdk/openai-compatible"), discriminatedUnion as any) + expect(out.type).toBe("object") + expect(out.oneOf).toBeUndefined() + }) + + test("leaves non-strict-schema providers untouched", () => { + const anthropic = model("@ai-sdk/anthropic") + const out: any = ProviderTransform.schema(anthropic, discriminatedUnion as any) + expect(out.oneOf).toBeDefined() + expect(out.type).toBeUndefined() + }) +}) diff --git a/backend/cli/test/provider/transform-deepseek.test.ts b/backend/cli/test/provider/transform-deepseek.test.ts new file mode 100644 index 00000000..2c2268d9 --- /dev/null +++ b/backend/cli/test/provider/transform-deepseek.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from "bun:test" +import { ProviderTransform } from "../../src/provider/transform" + +const sessionID = "sess-deepseek-1" + +const model = (overrides: Partial = {}): any => ({ + id: "deepseek/deepseek-v4-flash", + providerID: "deepseek", + api: { id: "deepseek-v4-flash", url: "https://api.deepseek.com", npm: "@ai-sdk/deepseek" }, + name: "DeepSeek V4 Flash", + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: { field: "reasoning_content" }, + }, + cost: { input: 0.14, output: 0.28, cache: { read: 0.0028, write: 0 } }, + limit: { context: 1_000_000, output: 384_000 }, + status: "active", + options: {}, + headers: {}, + release_date: "2026-07-31", + reasoningOptions: [{ type: "toggle" }, { type: "effort", values: ["high", "max"] }], + ...overrides, +}) + +describe("ProviderTransform.variants — deepseek native adapter", () => { + test("deepseek-v4 uses the catalog effort ladder via reasoningEffort", () => { + const v = ProviderTransform.variants(model()) + expect(v).toEqual({ + high: { reasoningEffort: "high" }, + max: { reasoningEffort: "max" }, + }) + }) + + test("deepseek-v4 without catalog efforts falls back to low/medium/high/max", () => { + const v = ProviderTransform.variants(model({ reasoningOptions: undefined })) + expect(Object.keys(v)).toEqual(["low", "medium", "high", "max"]) + expect(v.max).toEqual({ reasoningEffort: "max" }) + }) + + test("deepseek-v4 through openai-compatible maps the same ladder", () => { + const v = ProviderTransform.variants( + model({ + reasoningOptions: undefined, + api: { id: "deepseek-v4-flash", url: "https://api.deepseek.com", npm: "@ai-sdk/openai-compatible" }, + }), + ) + expect(Object.keys(v)).toEqual(["low", "medium", "high", "max"]) + }) + + test("non-v4 deepseek models expose no effort variants", () => { + expect( + ProviderTransform.variants( + model({ + id: "deepseek/deepseek-chat", + api: { id: "deepseek-chat", url: "https://api.deepseek.com", npm: "@ai-sdk/deepseek" }, + reasoningOptions: undefined, + }), + ), + ).toEqual({}) + }) + + test("a model configured as non-reasoning offers no variants", () => { + expect(ProviderTransform.variants(model({ capabilities: { ...model().capabilities, reasoning: false } }))).toEqual( + {}, + ) + }) +}) + +describe("ProviderTransform.options — deepseek thinking control", () => { + test("deepseek-v4 configured as non-reasoning disables thinking", () => { + const result = ProviderTransform.options({ + model: model({ capabilities: { ...model().capabilities, reasoning: false } }), + sessionID, + providerOptions: {}, + }) + expect(result.thinking).toEqual({ type: "disabled" }) + }) + + test("deepseek-v4 reasoning-capable keeps thinking enabled (no disable flag)", () => { + const result = ProviderTransform.options({ + model: model(), + sessionID, + providerOptions: {}, + }) + expect(result.thinking).toBeUndefined() + }) + + test("non-v4 deepseek models get no thinking option", () => { + const result = ProviderTransform.options({ + model: model({ api: { id: "deepseek-chat", url: "https://api.deepseek.com", npm: "@ai-sdk/deepseek" } }), + sessionID, + providerOptions: {}, + }) + expect(result.thinking).toBeUndefined() + }) + + test("deepseek-v4 on openai-compatible gets no thinking option", () => { + const result = ProviderTransform.options({ + model: model({ + api: { id: "deepseek-v4-flash", url: "https://api.deepseek.com", npm: "@ai-sdk/openai-compatible" }, + }), + sessionID, + providerOptions: {}, + }) + expect(result.thinking).toBeUndefined() + }) +}) + +describe("ProviderTransform.smallOptions — deepseek", () => { + test("small deepseek-v4 calls disable thinking", () => { + expect(ProviderTransform.smallOptions(model())).toEqual({ thinking: { type: "disabled" } }) + }) + + test("small non-v4 deepseek calls emit nothing", () => { + expect( + ProviderTransform.smallOptions( + model({ api: { id: "deepseek-chat", url: "https://api.deepseek.com", npm: "@ai-sdk/deepseek" } }), + ), + ).toEqual({}) + }) +}) diff --git a/bun.lock b/bun.lock index d76d161b..e0fbb677 100644 --- a/bun.lock +++ b/bun.lock @@ -33,6 +33,7 @@ "@ai-sdk/cerebras": "1.0.34", "@ai-sdk/cohere": "2.0.22", "@ai-sdk/deepinfra": "1.0.31", + "@ai-sdk/deepseek": "1.0.50", "@ai-sdk/gateway": "2.0.25", "@ai-sdk/google": "2.0.52", "@ai-sdk/google-vertex": "3.0.97", @@ -373,6 +374,8 @@ "@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-87qFcYNvDF/89hB//MQjYTb3tlsAfmgeZrZ34RESeBTZpSgs0EzYOMqPMwFTHUNp4wteoifikDJbaS/9Da8cfw=="], + "@ai-sdk/deepseek": ["@ai-sdk/deepseek@1.0.50", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.32" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Xil73QGHRoEkYmErW6nnLSA4VWSgdeIgQAeFEw7RILta8mwnLwHSsZxn0sffgQ3AfdWkM72Sm3Egal8/zVVK3w=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.25", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rq+FX55ne7lMiqai7NcvvDZj4HLsr+hg77WayqmySqc6zhw3tIOLxd4Ty6OpwNj0C0bVMi3iCl2zvJIEirh9XA=="], "@ai-sdk/google": ["@ai-sdk/google@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2XUnGi3f7TV4ujoAhA+Fg3idUoG/+Y2xjCRg70a1/m0DH1KSQqYaCboJ1C19y6ZHGdf5KNT20eJdswP6TvrY2g=="], @@ -2093,6 +2096,10 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@2.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww=="], + + "@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-izgUo50kamJMwoYCXoFwVccuJeyYp0y1+twf0FfpEz7kdQBloZLZbgLYUOUpannLWrV7xYSJR48BQJIQFbFiAQ=="], + "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -2251,6 +2258,8 @@ "zod-to-json-schema/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@grpc/proto-loader/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "@grpc/proto-loader/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],