Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions backend/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
80 changes: 73 additions & 7 deletions backend/cli/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ?? {},
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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<ReturnType<typeof state>>, 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<ReturnType<typeof state>>, providerID: string, modelID: string) {
const exact = s.providers[providerID]?.models[modelID]
return exact ?? resolveOpenRouterAlias(s, providerID, modelID)
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
}

Expand Down
99 changes: 99 additions & 0 deletions backend/cli/src/provider/tool-schema.ts
Original file line number Diff line number Diff line change
@@ -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] }
}
41 changes: 41 additions & 0 deletions backend/cli/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ModelsDev.Model["modalities"]>["input"][number]
Expand Down Expand Up @@ -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 }]))
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {}
}

Expand Down Expand Up @@ -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
}

Expand Down
Loading