mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(config): the DeepSeek catalog is one declaration, and its rates are one table
The flash model is `deepseek-flash` everywhere: the id it is served under lost the generation it never needed. An installed entry is renamed rather than left behind, and the rename moves every key that named the old id with it — a price or an effort list stranded under the old key stops answering for the model that replaced it, which reads downstream as a setting the user edited reverting to its default. A preset is now what settles which of its models read images. Reading a name misses every Kimi, Qwen, MiniMax and Claude model these presets declare, and `deepseek-flash` spells nothing at all; the spelling stays the last resort for an address nothing has declared, and stays a correctable suggestion rather than a claim. The connection that existed only to reach an image-taking model folds into the entry beside it once that entry reaches the same model on the same key — unless its user configured it, because folding would answer their edit by deleting where it lived. A preset changing shape reaches installs holding the old one through one declared shape per generation and one upgrade that brings a matching entry forward. The guard is the whole shape, because an entry holding what we shipped is ours to move and anything else its user curated. That retires nine hand-written migrations that each recognised one vendor by hand. Official list prices live once, in `officialRates`, oldest generation first. A price change appends rather than edits: the superseded rate is what lets an installed config be recognised as holding one of ours and brought forward, instead of quoting a retired rate for the rest of its life. `config` reads them through `billing.CurrentRate` and `SupersededRates` and restates none of them — a table written twice is one that drifts, which is how a vendor's USD rate ended up published on one side only. `make pricecheck` reads each vendor's own page and compares it against that table, and says how long ago a person last confirmed each one. It reaches the network, so it stays out of lint and out of CI. A page it cannot read exits non-zero: unread is its own verdict and never counts as agreement.
This commit is contained in:
1 parent
d3ab94b4dc
commit
e6db271d9f
83 files changed
+2377
-937
No files matched your search
@@ -18,6 +18,7 @@
|
||||
/reasonix-studio-host
|
||||
/signpath-contract
|
||||
/covergate
|
||||
/pricecheck
|
||||
/repolint
|
||||
/workgroupassay
|
||||
/write_heartbeat
|
||||
|
||||
@@ -9,7 +9,7 @@ GOEXE := $(shell go env GOEXE)
|
||||
# One pin for the Makefile and the CI lint job; see .github/workflows/ci.yml.
|
||||
GOLANGCI_VERSION := $(shell cat .golangci-version)
|
||||
|
||||
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update coverage-gate coverage-gate-update check test studio-test sdk-test sdk-test-race hooks cross clean studio
|
||||
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update coverage-gate coverage-gate-update pricecheck check test studio-test sdk-test sdk-test-race hooks cross clean studio
|
||||
|
||||
build:
|
||||
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix
|
||||
@@ -66,6 +66,13 @@ coverage-gate:
|
||||
coverage-gate-update:
|
||||
go run ./tools/covergate -update
|
||||
|
||||
# The vendors publish their rates on pages this reads and compares the table
|
||||
# against. It reaches the network, so it stays out of lint and out of CI: run it
|
||||
# before touching a rate and when a vendor announces a change. A page it cannot
|
||||
# read exits non-zero — unread never counts as agreement.
|
||||
pricecheck:
|
||||
go run ./tools/pricecheck
|
||||
|
||||
# Linting one GOOS leaves every //go:build windows and darwin file unchecked.
|
||||
lint-cross:
|
||||
@for t in "linux ." "darwin ." "windows ." "linux desktop" "windows desktop"; do \
|
||||
|
||||
+38
@@ -207,6 +207,44 @@ after the edit that enables it. Several edits to the *same* file are one
|
||||
`multi_edit` — it applies them against each other in memory and rewrites the
|
||||
file only if all of them land, so a failure midway leaves nothing half-edited.
|
||||
|
||||
## Vendor rates
|
||||
|
||||
Official list prices live in one place: `officialRates` in
|
||||
`internal/billing/rates.go`, oldest generation first, the last being what a
|
||||
model charges today. A price change is an **append, never an edit** — the
|
||||
superseded rate is what lets an installed config be recognised as holding one
|
||||
of ours and brought forward, instead of quoting a retired rate for the rest of
|
||||
its life. `config` reads these through `billing.CurrentRate` and
|
||||
`SupersededRates` and restates none of them; a table written twice is one that
|
||||
drifts, which is how a vendor's USD rate ended up published on one side only.
|
||||
|
||||
`make pricecheck` reads each vendor's own page and compares it against that
|
||||
table, and reports how long ago a person last confirmed each one. It reaches
|
||||
the network, so it stays out of `make lint` and out of CI: run it before
|
||||
touching a rate and when a vendor announces a change. A page it cannot read
|
||||
exits non-zero — unread is its own verdict and never counts as agreement.
|
||||
|
||||
## Curated presets
|
||||
|
||||
A preset changing shape reaches the installs that already have the old one
|
||||
through `shippedPresets` in `internal/config/preset_upgrade.go`: one declared
|
||||
shape per generation, and one upgrade that brings a matching entry forward. The
|
||||
guard is the whole shape — catalog, window, default — because an entry holding
|
||||
what we shipped is ours to move and anything else its user curated.
|
||||
|
||||
A preset is also what settles which of its models read images. Probing an
|
||||
address a preset covers answers from that declaration, because reading a name
|
||||
misses every Kimi, Qwen, MiniMax and Claude model they declare and `deepseek-
|
||||
flash` spells nothing at all; the spelling is the last resort for an address
|
||||
nothing has declared, and stays a correctable suggestion rather than a claim.
|
||||
|
||||
Three distinctions are load-bearing, and each was a separate migration function
|
||||
before they were written down. A window the shape did not name is the user's,
|
||||
so only a shape identified by its window moves one. `vision_models` **narrows**,
|
||||
so an upgrade marks only the models it adds and only where the preset says they
|
||||
read images. And an explicitly empty list is a choice — nothing here reads
|
||||
images — which is not the same as the nil that means nobody has said.
|
||||
|
||||
## Pre-push CI simulation
|
||||
|
||||
Run these **before every commit** to catch the fastest CI failures locally:
|
||||
|
||||
@@ -67,7 +67,7 @@ export class MockPort extends MockTheme implements AgentPort {
|
||||
// The subagent runs somewhere cheaper; everything else rides the main model.
|
||||
private assigned: RoleAssignments = {
|
||||
planner: "",
|
||||
subagent: "deepseek/deepseek-v4-flash",
|
||||
subagent: "deepseek/deepseek-flash",
|
||||
guardian: "",
|
||||
vision: "",
|
||||
};
|
||||
@@ -96,7 +96,7 @@ export class MockPort extends MockTheme implements AgentPort {
|
||||
efforts, effort: "high", contextWindow: 131072,
|
||||
},
|
||||
{
|
||||
ref: "deepseek/deepseek-v4-flash", provider: "deepseek", model: "deepseek-v4-flash",
|
||||
ref: "deepseek/deepseek-flash", provider: "deepseek", model: "deepseek-flash",
|
||||
kind: "openai", vendor: "api.deepseek.com", keyEnv: "DEEPSEEK_API_KEY", efforts, effort: "high",
|
||||
contextWindow: 131072, price: { input: 0.5, output: 2, currency: "CNY" },
|
||||
},
|
||||
@@ -137,7 +137,7 @@ export class MockPort extends MockTheme implements AgentPort {
|
||||
const amount = priced[day];
|
||||
return {
|
||||
day: at, total,
|
||||
byModel: (total ? { "deepseek/deepseek-v4-flash": total } : {}) as Record<string, number>,
|
||||
byModel: (total ? { "deepseek/deepseek-flash": total } : {}) as Record<string, number>,
|
||||
byProvider: (total ? { deepseek: total } : {}) as Record<string, number>,
|
||||
requests: Math.round(total / 20_000), turns: Math.round(total / 150_000),
|
||||
cacheHit: Math.round(total * 0.92), cacheMiss: Math.round(total * 0.08),
|
||||
@@ -153,11 +153,11 @@ export class MockPort extends MockTheme implements AgentPort {
|
||||
cache_miss: daily.reduce((a, d) => a + d.cacheMiss, 0),
|
||||
cost: [{ amount: "10.4882", currency: "CNY" }],
|
||||
active_days: daily.filter((d) => d.total > 0).length,
|
||||
top_model: "deepseek/deepseek-v4-flash", top_provider: "deepseek",
|
||||
top_model: "deepseek/deepseek-flash", top_provider: "deepseek",
|
||||
daily,
|
||||
models: [
|
||||
{ model: "deepseek/deepseek-v4-flash", provider: "deepseek", tokens: Math.round(tokens * 0.597), percent: 59.7 },
|
||||
{ model: "deepseek-flash/deepseek-v4-flash", provider: "deepseek-flash", tokens: Math.round(tokens * 0.401), percent: 40.1 },
|
||||
{ model: "deepseek/deepseek-flash", provider: "deepseek", tokens: Math.round(tokens * 0.597), percent: 59.7 },
|
||||
{ model: "deepseek-flash/deepseek-flash", provider: "deepseek-flash", tokens: Math.round(tokens * 0.401), percent: 40.1 },
|
||||
{ model: "deepseek/deepseek-v4-pro", provider: "deepseek", tokens: Math.round(tokens * 0.002), percent: 0.2 },
|
||||
],
|
||||
providers: [
|
||||
|
||||
@@ -19,7 +19,7 @@ export class MockProvider extends MockBoundary {
|
||||
private sources: ProviderEntry[] = [
|
||||
{
|
||||
name: "deepseek", kind: "openai", baseUrl: "https://api.deepseek.com",
|
||||
models: ["deepseek-v4-pro", "deepseek-v4-flash"], default: "deepseek-v4-pro",
|
||||
models: ["deepseek-v4-pro", "deepseek-flash"], default: "deepseek-v4-pro",
|
||||
hasKey: true, inUse: true, preset: false, keyEnv: "DEEPSEEK_API_KEY", canSetVision: false,
|
||||
},
|
||||
{
|
||||
@@ -69,7 +69,7 @@ export class MockProvider extends MockBoundary {
|
||||
async probeProvider(): Promise<ProviderProbe> {
|
||||
return {
|
||||
kind: "openai", kinds: ["openai", "responses"], authHeader: true,
|
||||
models: ["deepseek-v4-pro", "deepseek-v4-flash"], default: "deepseek-v4-pro",
|
||||
models: ["deepseek-v4-pro", "deepseek-flash"], default: "deepseek-v4-pro",
|
||||
efforts: ["high", "max"], effort: "high", vision: [],
|
||||
ambiguous: true, noProxy: false,
|
||||
};
|
||||
@@ -82,8 +82,8 @@ export class MockProvider extends MockBoundary {
|
||||
if (name === "mimo") return { ok: false, error: "401 unauthorized: key 过期了" };
|
||||
const models = name.startsWith("myrelay")
|
||||
? relayCatalog()
|
||||
: ["deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp"];
|
||||
const vision = models.filter((m) => m === "gpt-4o" || m === "deepseek-v4-flash-vision-exp");
|
||||
: ["deepseek-v4-pro", "deepseek-flash", "deepseek-flash-vision-exp"];
|
||||
const vision = models.filter((m) => m === "gpt-4o" || m === "deepseek-flash-vision-exp");
|
||||
return { ok: true, kind: "openai", models, vision, ambiguous: true };
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ function relayCatalog(): string[] {
|
||||
const families = [
|
||||
"qwen3-max", "qwen3-omni-flash", "qwen3-vl-plus", "qwen3-next-80b-a3b",
|
||||
"MiniMax-M2", "MiniMax/speech-02", "ZHIPU/GLM-5", "kimi-k2",
|
||||
"deepseek-v4-pro", "deepseek-v4-flash", "gpt-4o", "claude-sonnet-4",
|
||||
"deepseek-v4-pro", "deepseek-flash", "gpt-4o", "claude-sonnet-4",
|
||||
"gemini-3-pro", "llama-4-maverick", "mistral-large", "grok-4",
|
||||
];
|
||||
const out: string[] = [];
|
||||
|
||||
@@ -10,12 +10,12 @@ import { EditConn } from "./EditConn";
|
||||
afterEach(cleanup);
|
||||
|
||||
it("keeps vision capability discovered while refreshing a saved source", async () => {
|
||||
const visionModel = "deepseek-v4-flash-vision-exp";
|
||||
const visionModel = "deepseek-flash-vision-exp";
|
||||
const editProvider = vi.fn(async () => {});
|
||||
const port = {
|
||||
checkProvider: vi.fn(async () => ({
|
||||
ok: true,
|
||||
models: ["deepseek-v4-flash", visionModel],
|
||||
models: ["deepseek-flash", visionModel],
|
||||
vision: [visionModel],
|
||||
})),
|
||||
editProvider,
|
||||
@@ -24,8 +24,8 @@ it("keeps vision capability discovered while refreshing a saved source", async (
|
||||
name: "deepseek-flash",
|
||||
kind: "responses",
|
||||
baseUrl: "https://api.deepseek.com",
|
||||
models: ["deepseek-v4-flash"],
|
||||
default: "deepseek-v4-flash",
|
||||
models: ["deepseek-flash"],
|
||||
default: "deepseek-flash",
|
||||
hasKey: true,
|
||||
inUse: false,
|
||||
preset: true,
|
||||
|
||||
@@ -79,7 +79,7 @@ Example:
|
||||
|
||||
```toml
|
||||
config_version = 1
|
||||
default_model = "deepseek/deepseek-v4-flash"
|
||||
default_model = "deepseek/deepseek-flash"
|
||||
language = "zh"
|
||||
credentials_store = "auto" # legacy compatibility; provider keys are in .env
|
||||
|
||||
@@ -95,8 +95,8 @@ provider_access = ["deepseek"]
|
||||
name = "deepseek"
|
||||
kind = "anthropic"
|
||||
base_url = "https://api.deepseek.com/anthropic"
|
||||
models = ["deepseek-v4-flash", "deepseek-v4-pro"]
|
||||
default = "deepseek-v4-flash"
|
||||
models = ["deepseek-flash", "deepseek-v4-pro"]
|
||||
default = "deepseek-flash"
|
||||
api_key_env = "DEEPSEEK_API_KEY"
|
||||
web_search = true
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ bot 和 agent 设置。Provider 条目只保存 `api_key_env` 里的凭据变量
|
||||
|
||||
```toml
|
||||
config_version = 1
|
||||
default_model = "deepseek/deepseek-v4-flash"
|
||||
default_model = "deepseek/deepseek-flash"
|
||||
language = "zh"
|
||||
credentials_store = "auto" # 旧兼容字段;provider key 保存在 .env
|
||||
|
||||
@@ -77,8 +77,8 @@ provider_access = ["deepseek"]
|
||||
name = "deepseek"
|
||||
kind = "anthropic"
|
||||
base_url = "https://api.deepseek.com/anthropic"
|
||||
models = ["deepseek-v4-flash", "deepseek-v4-pro"]
|
||||
default = "deepseek-v4-flash"
|
||||
models = ["deepseek-flash", "deepseek-v4-pro"]
|
||||
default = "deepseek-flash"
|
||||
api_key_env = "DEEPSEEK_API_KEY"
|
||||
web_search = true
|
||||
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ reasoning_language = "auto" # visible reasoning text: auto|zh|en
|
||||
name = "deepseek-flash"
|
||||
kind = "anthropic"
|
||||
base_url = "https://api.deepseek.com/anthropic"
|
||||
model = "deepseek-v4-flash"
|
||||
model = "deepseek-flash"
|
||||
api_key_env = "DEEPSEEK_API_KEY"
|
||||
web_search = true
|
||||
# also preset: deepseek-pro
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ reasoning_language = "auto" # 可见思考过程语言:auto|zh|en
|
||||
name = "deepseek-flash"
|
||||
kind = "anthropic"
|
||||
base_url = "https://api.deepseek.com/anthropic"
|
||||
model = "deepseek-v4-flash"
|
||||
model = "deepseek-flash"
|
||||
api_key_env = "DEEPSEEK_API_KEY"
|
||||
web_search = true
|
||||
# 还有预设:deepseek-pro
|
||||
|
||||
+3
-3
@@ -1106,8 +1106,8 @@ kind = "anthropic"
|
||||
base_url = "https://api.deepseek.com/anthropic"
|
||||
# request_url = "https://proxy.example.com/anthropic/v1/messages" # optional exact provider request URL
|
||||
# models_url = "https://proxy.example.com/v1/models" # optional model discovery URL
|
||||
models = ["deepseek-v4-flash", "deepseek-v4-pro"]
|
||||
default = "deepseek-v4-flash" # optional; defaults to models[0]
|
||||
models = ["deepseek-flash", "deepseek-v4-pro"]
|
||||
default = "deepseek-flash" # optional; defaults to models[0]
|
||||
api_key_env = "DEEPSEEK_API_KEY"
|
||||
web_search = true
|
||||
context_window = 1000000 # tokens; harness compacts older history near this limit (0 disables)
|
||||
@@ -1116,7 +1116,7 @@ context_window = 1000000 # tokens; harness compacts older history near this li
|
||||
# max_output_tokens = 65536 # heavy reasoning / long tool loops
|
||||
# max_output_tokens = 131072 # only after repeated finish_reason=length
|
||||
# max_output_tokens never changes compact_ratio
|
||||
# model_overrides = { "deepseek-v4-flash" = { context_window = 1000000, max_output_tokens = 32768 } }
|
||||
# model_overrides = { "deepseek-flash" = { context_window = 1000000, max_output_tokens = 32768 } }
|
||||
|
||||
# A single-model entry still works for custom OpenAI-compatible endpoints.
|
||||
|
||||
|
||||
+2
-2
@@ -420,8 +420,8 @@ name = "deepseek"
|
||||
kind = "anthropic"
|
||||
base_url = "https://api.deepseek.com/anthropic"
|
||||
# request_url = "https://proxy.example.com/anthropic/v1/messages" # 可选:完整请求地址
|
||||
models = ["deepseek-v4-flash", "deepseek-v4-pro"]
|
||||
default = "deepseek-v4-flash"
|
||||
models = ["deepseek-flash", "deepseek-v4-pro"]
|
||||
default = "deepseek-flash"
|
||||
api_key_env = "DEEPSEEK_API_KEY"
|
||||
web_search = true
|
||||
context_window = 1000000
|
||||
|
||||
+1
-1
@@ -172,7 +172,7 @@
|
||||
<div class="term">
|
||||
<div class="tbar"><i></i><i></i><i></i><span>~/app — reasonix</span></div>
|
||||
<div class="tbody"><span class="u">$</span> reasonix
|
||||
<span class="i">reasonix v1.0.0 · model deepseek-v4-flash · ~/app</span>
|
||||
<span class="i">reasonix v1.0.0 · model deepseek-flash · ~/app</span>
|
||||
<span class="d">cache 94.2% hit · session 18m · cost $0.043</span>
|
||||
<span class="u">›</span> add retry with backoff to the http client
|
||||
<span class="e">edit</span> internal/net/client.go <span class="c">+24 −3</span>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<rect width="1180" height="760" rx="18" fill="#343443"/>
|
||||
<rect x="34" y="34" width="1112" height="692" rx="14" fill="#3d3d4f"/>
|
||||
<text x="64" y="82" fill="#f0b07f" font-family="Menlo, Consolas, monospace" font-size="27" font-weight="700">◆ reasonix</text>
|
||||
<text x="314" y="82" fill="#a9a9bf" font-family="Menlo, Consolas, monospace" font-size="27">· deepseek-v4-flash</text>
|
||||
<text x="314" y="82" fill="#a9a9bf" font-family="Menlo, Consolas, monospace" font-size="27">· deepseek-flash</text>
|
||||
|
||||
<g font-family="Menlo, Consolas, monospace" font-size="24">
|
||||
<text x="70" y="137" fill="#a9a9bf">› /help</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
@@ -44,6 +44,11 @@ var deepseekPeak = &PeakWindow{
|
||||
// checkedOn is when the tables below were last read off the vendors' pages.
|
||||
const checkedOn = "2026-08-22"
|
||||
|
||||
// deepseekCheckedOn is when the DeepSeek rows were last read off that vendor's
|
||||
// page. Separate from checkedOn, because confirming one vendor's prices says
|
||||
// nothing about another's and a shared date would claim that it did.
|
||||
const deepseekCheckedOn = "2026-09-13"
|
||||
|
||||
// CatalogSourceURLs document public pricing pages.
|
||||
const (
|
||||
DocDeepSeekPricing = "https://api-docs.deepseek.com/quick_start/pricing"
|
||||
@@ -76,38 +81,11 @@ func OfficialProviderForEndpoint(baseURL string) string {
|
||||
return officialEndpointHosts[strings.ToLower(u.Hostname())]
|
||||
}
|
||||
|
||||
// OfficialCatalog is the built-in price book. Rates match config defaults.
|
||||
// OfficialCatalog is the built-in price book: the current generation of every
|
||||
// rate in officialRates, fingerprinted so a stored price can be recognised as
|
||||
// one of ours. User-custom prices always win over it.
|
||||
func OfficialCatalog() []CatalogEntry {
|
||||
entries := []CatalogEntry{
|
||||
// DeepSeek regional tables (official pricing doc). The base rate is the
|
||||
// off-peak one, which is what a caller charging a single rate should
|
||||
// quote; Peak is the same tokens inside deepseekPeak's hours.
|
||||
{Provider: "deepseek", Model: "deepseek-v4-flash", Currency: "CNY", CacheHit: 0.05, Input: 1.5, Output: 4.5, Peak: &RateCard{CacheHit: 0.10, Input: 3.0, Output: 9.0}, Window: deepseekPeak, DocURL: DocDeepSeekPricing, BillingMode: BillingModePAYG, CheckedOn: checkedOn},
|
||||
{Provider: "deepseek", Model: "deepseek-v4-pro", Currency: "CNY", CacheHit: 0.15, Input: 4.5, Output: 13.5, Peak: &RateCard{CacheHit: 0.30, Input: 9.0, Output: 27.0}, Window: deepseekPeak, DocURL: DocDeepSeekPricing, BillingMode: BillingModePAYG, CheckedOn: checkedOn},
|
||||
{Provider: "deepseek", Model: "deepseek-v4-flash", Currency: "USD", CacheHit: 0.007, Input: 0.22, Output: 0.66, Peak: &RateCard{CacheHit: 0.014, Input: 0.44, Output: 1.32}, Window: deepseekPeak, DocURL: DocDeepSeekPricing, BillingMode: BillingModePAYG, CheckedOn: checkedOn},
|
||||
{Provider: "deepseek", Model: "deepseek-v4-pro", Currency: "USD", CacheHit: 0.022, Input: 0.66, Output: 1.98, Peak: &RateCard{CacheHit: 0.044, Input: 1.32, Output: 3.96}, Window: deepseekPeak, DocURL: DocDeepSeekPricing, BillingMode: BillingModePAYG, CheckedOn: checkedOn},
|
||||
// Vision bills at the flash rates; an image is charged as the tokens it
|
||||
// scales to, so it arrives already counted in the prompt total and needs
|
||||
// no per-image rate of its own.
|
||||
{Provider: "deepseek", Model: "deepseek-v4-flash-vision-exp", Currency: "CNY", CacheHit: 0.05, Input: 1.5, Output: 4.5, Peak: &RateCard{CacheHit: 0.10, Input: 3.0, Output: 9.0}, Window: deepseekPeak, DocURL: DocDeepSeekPricing, BillingMode: BillingModePAYG, CheckedOn: checkedOn},
|
||||
{Provider: "deepseek", Model: "deepseek-v4-flash-vision-exp", Currency: "USD", CacheHit: 0.007, Input: 0.22, Output: 0.66, Peak: &RateCard{CacheHit: 0.014, Input: 0.44, Output: 1.32}, Window: deepseekPeak, DocURL: DocDeepSeekPricing, BillingMode: BillingModePAYG, CheckedOn: checkedOn},
|
||||
|
||||
// LongCat dual currency tables: the launch discount, for which the vendor
|
||||
// publishes no end date. List price is CNY 0.10/5/20, USD 0.015/0.75/2.95
|
||||
// — what these revert to, and why CheckedOn earns its place here.
|
||||
{Provider: "longcat", Model: "LongCat-2.0", Currency: "CNY", CacheHit: 0.04, Input: 2, Output: 8, DocURL: DocLongCatPricingCNY, BillingMode: BillingModePAYG, CheckedOn: checkedOn, Notes: "launch_discount_no_end_date"},
|
||||
{Provider: "longcat", Model: "LongCat-2.0", Currency: "USD", CacheHit: 0.006, Input: 0.30, Output: 1.20, DocURL: DocLongCatPricingUSD, BillingMode: BillingModePAYG, CheckedOn: checkedOn, Notes: "launch_discount_no_end_date"},
|
||||
|
||||
// MiMo domestic PAYG; Token Plan uses the same rates as subscription_equivalent.
|
||||
{Provider: "mimo", Model: "mimo-v2.5-pro", Currency: "CNY", CacheHit: 0.025, Input: 3, Output: 6, DocURL: DocMiMoPAYG, BillingMode: BillingModePAYG, CheckedOn: checkedOn},
|
||||
{Provider: "mimo", Model: "mimo-v2.5", Currency: "CNY", CacheHit: 0.02, Input: 1, Output: 2, DocURL: DocMiMoPAYG, BillingMode: BillingModePAYG, CheckedOn: checkedOn},
|
||||
// Not on the vendor's price page as of CheckedOn. Kept because removing a
|
||||
// rate is what turns a still-configured model's cost silently into zero;
|
||||
// a stale rate at least reads as a number somebody can question.
|
||||
{Provider: "mimo", Model: "mimo-v2-flash", Currency: "CNY", CacheHit: 0.07, Input: 0.70, Output: 2.10, DocURL: DocMiMoPAYG, BillingMode: BillingModePAYG, CheckedOn: checkedOn, Notes: "not_on_vendor_page"},
|
||||
{Provider: "mimo", Model: "mimo-v2.5-pro", Currency: "CNY", CacheHit: 0.025, Input: 3, Output: 6, DocURL: DocMiMoTokenPlan, BillingMode: BillingModeSubscriptionEquivalent, Notes: "payg_equivalent_not_plan_bill", CheckedOn: checkedOn},
|
||||
{Provider: "mimo", Model: "mimo-v2.5", Currency: "CNY", CacheHit: 0.02, Input: 1, Output: 2, DocURL: DocMiMoTokenPlan, BillingMode: BillingModeSubscriptionEquivalent, Notes: "payg_equivalent_not_plan_bill", CheckedOn: checkedOn},
|
||||
}
|
||||
entries := officialCatalogRows()
|
||||
for i := range entries {
|
||||
entries[i].Fingerprint = PricingFingerprint(RateCard{
|
||||
CacheHit: entries[i].CacheHit,
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
)
|
||||
|
||||
func testInput(currency, provider, model string) QuoteInput {
|
||||
rates := RateCard{CacheHit: 0.05, Input: 1.5, Output: 4.5, Currency: currency}
|
||||
rates := RateCard{CacheHit: 0.02, Input: 1, Output: 4, Currency: currency}
|
||||
if currency == "USD" {
|
||||
rates = RateCard{CacheHit: 0.007, Input: 0.22, Output: 0.66, Currency: currency}
|
||||
rates = RateCard{CacheHit: 0.003, Input: 0.15, Output: 0.6, Currency: currency}
|
||||
}
|
||||
return QuoteInput{
|
||||
Usage: UsageTokens{PromptTokens: 1000, CompletionTokens: 2000},
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
// rates.go — every rate this project has shipped for a vendor's model, and the
|
||||
// two questions that asks: what it costs now, and what it used to cost.
|
||||
package billing
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// rateGeneration is one published pair. Peak is nil for a generation the vendor
|
||||
// billed at a single rate around the clock.
|
||||
type rateGeneration struct {
|
||||
Base RateCard
|
||||
Peak *RateCard
|
||||
}
|
||||
|
||||
// officialRateRow is one vendor's model, in one currency, under one billing
|
||||
// mode, with every rate this project has shipped for it.
|
||||
type officialRateRow struct {
|
||||
Provider string
|
||||
Model string
|
||||
Currency string
|
||||
BillingMode string
|
||||
Window *PeakWindow
|
||||
DocURL string
|
||||
Notes string
|
||||
CheckedOn string
|
||||
// Rates is oldest first; the last entry is what this costs today.
|
||||
Rates []rateGeneration
|
||||
}
|
||||
|
||||
// officialRates is the price book. A price change appends a generation rather
|
||||
// than editing one: that is what leaves the superseded rate where a stored price
|
||||
// can still be recognised as this project's own default instead of a number its
|
||||
// user chose, which is the difference between refreshing one and losing it.
|
||||
var officialRates = []officialRateRow{
|
||||
{
|
||||
Provider: "deepseek", Model: "deepseek-flash", Currency: "CNY",
|
||||
BillingMode: BillingModePAYG, Window: deepseekPeak, DocURL: DocDeepSeekPricing, CheckedOn: deepseekCheckedOn,
|
||||
Rates: []rateGeneration{
|
||||
{Base: RateCard{CacheHit: 0.02, Input: 1, Output: 2, Currency: "CNY"}},
|
||||
{Base: RateCard{CacheHit: 0.05, Input: 1.5, Output: 4.5, Currency: "CNY"},
|
||||
Peak: &RateCard{CacheHit: 0.10, Input: 3.0, Output: 9.0, Currency: "CNY"}},
|
||||
{Base: RateCard{CacheHit: 0.02, Input: 1, Output: 4, Currency: "CNY"},
|
||||
Peak: &RateCard{CacheHit: 0.04, Input: 2, Output: 8, Currency: "CNY"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Provider: "deepseek", Model: "deepseek-flash", Currency: "USD",
|
||||
BillingMode: BillingModePAYG, Window: deepseekPeak, DocURL: DocDeepSeekPricing, CheckedOn: deepseekCheckedOn,
|
||||
Rates: []rateGeneration{
|
||||
{Base: RateCard{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: "USD"}},
|
||||
{Base: RateCard{CacheHit: 0.007, Input: 0.22, Output: 0.66, Currency: "USD"},
|
||||
Peak: &RateCard{CacheHit: 0.014, Input: 0.44, Output: 1.32, Currency: "USD"}},
|
||||
{Base: RateCard{CacheHit: 0.003, Input: 0.15, Output: 0.6, Currency: "USD"},
|
||||
Peak: &RateCard{CacheHit: 0.006, Input: 0.3, Output: 1.2, Currency: "USD"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Provider: "deepseek", Model: "deepseek-v4-pro", Currency: "CNY",
|
||||
BillingMode: BillingModePAYG, Window: deepseekPeak, DocURL: DocDeepSeekPricing, CheckedOn: deepseekCheckedOn,
|
||||
Rates: []rateGeneration{
|
||||
{Base: RateCard{CacheHit: 0.025, Input: 3, Output: 6, Currency: "CNY"}},
|
||||
{Base: RateCard{CacheHit: 0.15, Input: 4.5, Output: 13.5, Currency: "CNY"},
|
||||
Peak: &RateCard{CacheHit: 0.30, Input: 9.0, Output: 27.0, Currency: "CNY"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Provider: "deepseek", Model: "deepseek-v4-pro", Currency: "USD",
|
||||
BillingMode: BillingModePAYG, Window: deepseekPeak, DocURL: DocDeepSeekPricing, CheckedOn: deepseekCheckedOn,
|
||||
Rates: []rateGeneration{
|
||||
{Base: RateCard{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: "USD"}},
|
||||
{Base: RateCard{CacheHit: 0.022, Input: 0.66, Output: 1.98, Currency: "USD"},
|
||||
Peak: &RateCard{CacheHit: 0.044, Input: 1.32, Output: 3.96, Currency: "USD"}},
|
||||
},
|
||||
},
|
||||
|
||||
// LongCat's launch discount, for which the vendor publishes no end date. List
|
||||
// price is CNY 0.10/5/20, USD 0.015/0.75/2.95 — what these revert to, and why
|
||||
// CheckedOn earns its place here.
|
||||
{
|
||||
Provider: "longcat", Model: "LongCat-2.0", Currency: "CNY",
|
||||
BillingMode: BillingModePAYG, DocURL: DocLongCatPricingCNY, Notes: "launch_discount_no_end_date", CheckedOn: checkedOn,
|
||||
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.04, Input: 2, Output: 8, Currency: "CNY"}}},
|
||||
},
|
||||
{
|
||||
Provider: "longcat", Model: "LongCat-2.0", Currency: "USD",
|
||||
BillingMode: BillingModePAYG, DocURL: DocLongCatPricingUSD, Notes: "launch_discount_no_end_date", CheckedOn: checkedOn,
|
||||
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.006, Input: 0.30, Output: 1.20, Currency: "USD"}}},
|
||||
},
|
||||
|
||||
// MiMo domestic PAYG; Token Plan bills the same rates as subscription_equivalent.
|
||||
{
|
||||
Provider: "mimo", Model: "mimo-v2.5-pro", Currency: "CNY",
|
||||
BillingMode: BillingModePAYG, DocURL: DocMiMoPAYG, CheckedOn: checkedOn,
|
||||
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.025, Input: 3, Output: 6, Currency: "CNY"}}},
|
||||
},
|
||||
{
|
||||
Provider: "mimo", Model: "mimo-v2.5", Currency: "CNY",
|
||||
BillingMode: BillingModePAYG, DocURL: DocMiMoPAYG, CheckedOn: checkedOn,
|
||||
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.02, Input: 1, Output: 2, Currency: "CNY"}}},
|
||||
},
|
||||
// Not on the vendor's price page as of CheckedOn. Kept because removing a rate
|
||||
// is what turns a still-configured model's cost silently into zero; a stale
|
||||
// rate at least reads as a number somebody can question.
|
||||
{
|
||||
Provider: "mimo", Model: "mimo-v2-flash", Currency: "CNY",
|
||||
BillingMode: BillingModePAYG, DocURL: DocMiMoPAYG, Notes: "not_on_vendor_page", CheckedOn: checkedOn,
|
||||
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: "CNY"}}},
|
||||
},
|
||||
{
|
||||
Provider: "mimo", Model: "mimo-v2.5-pro", Currency: "CNY",
|
||||
BillingMode: BillingModeSubscriptionEquivalent, DocURL: DocMiMoTokenPlan, Notes: "payg_equivalent_not_plan_bill", CheckedOn: checkedOn,
|
||||
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.025, Input: 3, Output: 6, Currency: "CNY"}}},
|
||||
},
|
||||
{
|
||||
Provider: "mimo", Model: "mimo-v2.5", Currency: "CNY",
|
||||
BillingMode: BillingModeSubscriptionEquivalent, DocURL: DocMiMoTokenPlan, Notes: "payg_equivalent_not_plan_bill", CheckedOn: checkedOn,
|
||||
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.02, Input: 1, Output: 2, Currency: "CNY"}}},
|
||||
},
|
||||
}
|
||||
|
||||
// retiredDeepSeekFlashModels are names DeepSeek still answers as its current
|
||||
// flash model, at that model's price. Owned here because that is where the rates
|
||||
// they bill at live; config reads them through the accessor below.
|
||||
var retiredDeepSeekFlashModels = []string{"deepseek-v4-flash", "deepseek-v4-flash-vision-exp"}
|
||||
|
||||
// RetiredDeepSeekFlashModels copies the list on the way out: a caller sorting or
|
||||
// trimming the result would otherwise change what every other caller sees.
|
||||
func RetiredDeepSeekFlashModels() []string {
|
||||
return append([]string(nil), retiredDeepSeekFlashModels...)
|
||||
}
|
||||
|
||||
// RateModelFor answers which model's rate a name is billed at — a retired name
|
||||
// at the model that replaced it, an older spelling at the one it was renamed
|
||||
// from. Scoped to billing on purpose: two models can share a rate without being
|
||||
// the same model, and nothing outside a price lookup may read this as identity.
|
||||
func RateModelFor(provider, model string) string {
|
||||
model = strings.TrimSpace(model)
|
||||
switch strings.ToLower(strings.TrimSpace(provider)) {
|
||||
case "deepseek":
|
||||
if slices.Contains(retiredDeepSeekFlashModels, model) {
|
||||
return "deepseek-flash"
|
||||
}
|
||||
case "mimo":
|
||||
switch model {
|
||||
case "mimo-v2-pro":
|
||||
return "mimo-v2.5-pro"
|
||||
case "mimo-v2-omni":
|
||||
return "mimo-v2.5"
|
||||
}
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
// DefaultCurrency is what a vendor's rates are quoted in when an entry does not
|
||||
// say. A vendor publishing one currency leaves no choice to make; DeepSeek
|
||||
// prices in both, and USD is what an entry that never chose has been read as.
|
||||
func DefaultCurrency(provider string) string {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
seen := map[string]bool{}
|
||||
for _, row := range officialRates {
|
||||
if row.Provider == provider {
|
||||
seen[row.Currency] = true
|
||||
}
|
||||
}
|
||||
if len(seen) == 1 {
|
||||
for currency := range seen {
|
||||
return currency
|
||||
}
|
||||
}
|
||||
return "USD"
|
||||
}
|
||||
|
||||
// officialRateRowsFor finds the rows for a model, which is more than one when a
|
||||
// vendor publishes the same rates under two billing modes.
|
||||
func officialRateRowsFor(provider, model, currency string) []officialRateRow {
|
||||
provider, currency = strings.ToLower(strings.TrimSpace(provider)), NormalizeCurrency(currency)
|
||||
model = RateModelFor(provider, model)
|
||||
var out []officialRateRow
|
||||
for _, row := range officialRates {
|
||||
if row.Provider == provider && row.Model == model && row.Currency == currency {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CurrentRate is what a vendor's model costs in a currency today — the off-peak
|
||||
// card, which is what a caller charging one rate should quote. Nil when this
|
||||
// catalog prices neither. A model published under two billing modes quotes the
|
||||
// pay-as-you-go rate, which is the one an unsubscribed caller is charged.
|
||||
func CurrentRate(provider, model, currency string) *RateCard {
|
||||
for _, row := range officialRateRowsFor(provider, model, currency) {
|
||||
if row.BillingMode == BillingModeSubscriptionEquivalent {
|
||||
continue
|
||||
}
|
||||
if len(row.Rates) > 0 {
|
||||
rate := row.Rates[len(row.Rates)-1].Base
|
||||
return &rate
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SupersededRates are the rates shipped for a model before the current one. A
|
||||
// stored price matching one came from this project rather than from its user, so
|
||||
// a refresh may replace it; anything else is theirs and is left alone.
|
||||
func SupersededRates(provider, model, currency string) []RateCard {
|
||||
var out []RateCard
|
||||
for _, row := range officialRateRowsFor(provider, model, currency) {
|
||||
if row.BillingMode == BillingModeSubscriptionEquivalent || len(row.Rates) < 2 {
|
||||
continue
|
||||
}
|
||||
for _, generation := range row.Rates[:len(row.Rates)-1] {
|
||||
out = append(out, generation.Base)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// officialCatalogRows publishes the current generation of every row, plus the
|
||||
// retired DeepSeek names, which bill at the flash rate under their own id.
|
||||
func officialCatalogRows() []CatalogEntry {
|
||||
rows := make([]CatalogEntry, 0, len(officialRates)+len(retiredDeepSeekFlashModels)*2)
|
||||
for _, row := range officialRates {
|
||||
if len(row.Rates) == 0 {
|
||||
continue
|
||||
}
|
||||
current := row.Rates[len(row.Rates)-1]
|
||||
rows = append(rows, row.entry(row.Model, current))
|
||||
if row.Provider != "deepseek" || row.Model != "deepseek-flash" {
|
||||
continue
|
||||
}
|
||||
for _, retired := range retiredDeepSeekFlashModels {
|
||||
rows = append(rows, row.entry(retired, current))
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (r officialRateRow) entry(model string, current rateGeneration) CatalogEntry {
|
||||
// Copied, so a caller holding a returned row cannot reach back into the
|
||||
// table every other caller reads.
|
||||
var peak *RateCard
|
||||
if current.Peak != nil {
|
||||
card := *current.Peak
|
||||
peak = &card
|
||||
}
|
||||
return CatalogEntry{
|
||||
Provider: r.Provider, Model: model, Currency: r.Currency,
|
||||
CacheHit: current.Base.CacheHit, Input: current.Base.Input, Output: current.Base.Output,
|
||||
Peak: peak, Window: r.Window, DocURL: r.DocURL,
|
||||
BillingMode: r.BillingMode, Notes: r.Notes, CheckedOn: r.CheckedOn,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package billing
|
||||
|
||||
import "testing"
|
||||
|
||||
// The history answers one question: is a stored price the current default, or a
|
||||
// superseded one this project may replace? A generation repeating another in the
|
||||
// same row makes that unanswerable — the current rate would match as superseded,
|
||||
// and every refresh would rewrite it while reporting that something changed.
|
||||
func TestRateHistoryHasNoRepeatedGenerations(t *testing.T) {
|
||||
for _, row := range officialRates {
|
||||
if len(row.Rates) == 0 {
|
||||
t.Errorf("%s/%s/%s has no rate at all", row.Provider, row.Model, row.Currency)
|
||||
continue
|
||||
}
|
||||
seen := map[RateCard]bool{}
|
||||
for _, generation := range row.Rates {
|
||||
if seen[generation.Base] {
|
||||
t.Errorf("%s/%s/%s repeats the rate %+v", row.Provider, row.Model, row.Currency, generation.Base)
|
||||
}
|
||||
seen[generation.Base] = true
|
||||
if generation.Base.Currency != row.Currency {
|
||||
t.Errorf("%s/%s/%s holds a rate filed as %q", row.Provider, row.Model, row.Currency, generation.Base.Currency)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CurrentRate returns the first row it matches, so two rows sharing all four
|
||||
// identifying fields make which rate a caller gets depend on table order.
|
||||
func TestOfficialRateRowsAreUniquelyIdentified(t *testing.T) {
|
||||
type key struct{ provider, model, currency, mode string }
|
||||
seen := map[key]bool{}
|
||||
for _, row := range officialRates {
|
||||
k := key{row.Provider, row.Model, row.Currency, row.BillingMode}
|
||||
if seen[k] {
|
||||
t.Errorf("%+v appears twice; which rate CurrentRate returns is then a matter of order", k)
|
||||
}
|
||||
seen[k] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Superseded means "no longer charged". Returning the current rate among them
|
||||
// would make every refresh a no-op that still reports having changed something.
|
||||
func TestSupersededRatesExcludeTheCurrentOne(t *testing.T) {
|
||||
for _, row := range officialRates {
|
||||
current := CurrentRate(row.Provider, row.Model, row.Currency)
|
||||
if current == nil {
|
||||
if row.BillingMode != BillingModeSubscriptionEquivalent {
|
||||
t.Errorf("%s/%s/%s has no current rate", row.Provider, row.Model, row.Currency)
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, superseded := range SupersededRates(row.Provider, row.Model, row.Currency) {
|
||||
if superseded == *current {
|
||||
t.Errorf("%s/%s/%s lists its current rate as superseded", row.Provider, row.Model, row.Currency)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A retired name bills at the flash rate, which is the whole reason the catalog
|
||||
// publishes a row for it; resolving it anywhere else would price it as nothing.
|
||||
func TestRetiredDeepSeekNamesBillAtTheFlashRate(t *testing.T) {
|
||||
for _, retired := range RetiredDeepSeekFlashModels() {
|
||||
for _, currency := range []string{"CNY", "USD"} {
|
||||
got := CurrentRate("deepseek", retired, currency)
|
||||
want := CurrentRate("deepseek", "deepseek-flash", currency)
|
||||
if got == nil || want == nil || *got != *want {
|
||||
t.Errorf("%s/%s = %+v, want the flash rate %+v", retired, currency, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every row the table declares has to reach the catalog, which is what a quote
|
||||
// actually reads; a rate declared but never published quotes as nothing.
|
||||
func TestEveryRateRowReachesTheCatalog(t *testing.T) {
|
||||
published := map[string]bool{}
|
||||
for _, entry := range OfficialCatalog() {
|
||||
published[entry.Provider+"/"+entry.Model+"/"+entry.Currency+"/"+entry.BillingMode] = true
|
||||
}
|
||||
for _, row := range officialRates {
|
||||
if !published[row.Provider+"/"+row.Model+"/"+row.Currency+"/"+row.BillingMode] {
|
||||
t.Errorf("%s/%s/%s/%s is declared but never published", row.Provider, row.Model, row.Currency, row.BillingMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1728,7 +1728,7 @@ func TestNewProviderBuildsDeepSeekAnthropicPreset(t *testing.T) {
|
||||
if err := cfg.UpsertProvider(preset.Entries[0]); err != nil {
|
||||
t.Fatalf("UpsertProvider: %v", err)
|
||||
}
|
||||
entry, ok := cfg.ResolveModel("deepseek-anthropic/deepseek-v4-flash")
|
||||
entry, ok := cfg.ResolveModel("deepseek-anthropic/deepseek-flash")
|
||||
if !ok {
|
||||
t.Fatal("ResolveModel failed")
|
||||
}
|
||||
@@ -3422,7 +3422,7 @@ func TestBuildMigratesLegacyDeepSeekProtocolWithOneNotice(t *testing.T) {
|
||||
if err := os.MkdirAll(filepath.Dir(userPath), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(userPath, []byte(`default_model = "deepseek-flash/deepseek-v4-flash"
|
||||
if err := os.WriteFile(userPath, []byte(`default_model = "deepseek-flash/deepseek-flash"
|
||||
|
||||
[[providers]]
|
||||
name = "deepseek-flash"
|
||||
|
||||
@@ -45,7 +45,7 @@ func TestCostQuoteReachesInnerSinkBeforeRecording(t *testing.T) {
|
||||
quoted := event.NewCostQuoteSink(inner, &event.QuoteContext{DisplayCurrency: "USD", Now: offPeak, CatalogProviderForModel: onDeepSeeksOwnEndpoint})
|
||||
quoted.Emit(event.Event{
|
||||
Kind: event.Usage,
|
||||
ModelRef: "deepseek-flash/deepseek-v4-flash",
|
||||
ModelRef: "deepseek-flash/deepseek-flash",
|
||||
Usage: &provider.Usage{PromptTokens: 1_000_000, CompletionTokens: 0, TotalTokens: 1_000_000},
|
||||
Pricing: &provider.Pricing{CacheHit: 0.007, Input: 0.22, Output: 0.66, Currency: "$"},
|
||||
})
|
||||
@@ -70,9 +70,9 @@ func TestCostQuoteReachesInnerSinkBeforeRecording(t *testing.T) {
|
||||
}), &event.QuoteContext{DisplayCurrency: "USD", Now: offPeak, CatalogProviderForModel: onDeepSeeksOwnEndpoint})
|
||||
quoted2.Emit(event.Event{
|
||||
Kind: event.Usage,
|
||||
ModelRef: "deepseek-flash/deepseek-v4-flash",
|
||||
ModelRef: "deepseek-flash/deepseek-flash",
|
||||
Usage: &provider.Usage{PromptTokens: 1_000_000, CompletionTokens: 1_000_000, TotalTokens: 2_000_000},
|
||||
Pricing: &provider.Pricing{CacheHit: 0.05, Input: 1.5, Output: 4.5, Currency: "¥"},
|
||||
Pricing: &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 4, Currency: "¥"},
|
||||
})
|
||||
if basis != billing.BasisOfficialTable {
|
||||
t.Fatalf("USD valuation basis = %q, want official_table", basis)
|
||||
|
||||
@@ -217,7 +217,7 @@ func TestBuildKeylessDefaultFallsBackToConfiguredProvider(t *testing.T) {
|
||||
fenceBootTestHistoryCatalog(t)
|
||||
t.Chdir(dir)
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "deepseek/deepseek-v4-flash"
|
||||
default_model = "deepseek/deepseek-flash"
|
||||
|
||||
[[providers]]
|
||||
name = "deepseek"
|
||||
@@ -289,7 +289,7 @@ api_key_env = "`+configuredEnv+`"
|
||||
|
||||
_, err := Build(context.Background(), Options{
|
||||
Sink: event.Discard,
|
||||
Model: "deepseek/deepseek-v4-flash",
|
||||
Model: "deepseek/deepseek-flash",
|
||||
RequireKey: true,
|
||||
})
|
||||
if err == nil {
|
||||
|
||||
@@ -2380,7 +2380,7 @@ func TestEffortCommandWritesCurrentDeepSeekProvider(t *testing.T) {
|
||||
|
||||
m := newTestChatTUI()
|
||||
m.ctrl = control.New(control.Options{Label: "deepseek-flash"})
|
||||
m.modelRef = "deepseek-flash/deepseek-v4-flash"
|
||||
m.modelRef = "deepseek-flash/deepseek-flash"
|
||||
m.buildController = func(_ controllerBuildSpec, _ []provider.Message, _ string, _ control.SessionAPI) (*control.Controller, error) {
|
||||
return control.New(control.Options{Label: "deepseek-flash"}), nil
|
||||
}
|
||||
@@ -2423,7 +2423,7 @@ func TestEffortCommandAutoClearsProviderEffort(t *testing.T) {
|
||||
|
||||
m := newTestChatTUI()
|
||||
m.ctrl = control.New(control.Options{Label: "deepseek-flash"})
|
||||
m.modelRef = "deepseek-flash/deepseek-v4-flash"
|
||||
m.modelRef = "deepseek-flash/deepseek-flash"
|
||||
m.buildController = func(_ controllerBuildSpec, _ []provider.Message, _ string, _ control.SessionAPI) (*control.Controller, error) {
|
||||
return control.New(control.Options{Label: "deepseek-flash"}), nil
|
||||
}
|
||||
@@ -2528,7 +2528,7 @@ func TestLanguageCommandRefreshesCurrentController(t *testing.T) {
|
||||
t.Cleanup(oldCtrl.Close)
|
||||
m := newTestChatTUI()
|
||||
m.ctrl = oldCtrl
|
||||
m.modelRef = "deepseek-flash/deepseek-v4-flash"
|
||||
m.modelRef = "deepseek-flash/deepseek-flash"
|
||||
m.runtimeProfile = "full"
|
||||
var gotSpec controllerBuildSpec
|
||||
m.buildController = func(spec controllerBuildSpec, _ []provider.Message, _ string, _ control.SessionAPI) (*control.Controller, error) {
|
||||
@@ -2560,7 +2560,7 @@ func TestCurrencyCommandPersistsAndRefreshesCurrentController(t *testing.T) {
|
||||
t.Cleanup(oldCtrl.Close)
|
||||
m := newTestChatTUI()
|
||||
m.ctrl = oldCtrl
|
||||
m.modelRef = "deepseek-flash/deepseek-v4-flash"
|
||||
m.modelRef = "deepseek-flash/deepseek-flash"
|
||||
m.runtimeProfile = "full"
|
||||
var gotSpec controllerBuildSpec
|
||||
m.buildController = func(spec controllerBuildSpec, _ []provider.Message, _ string, _ control.SessionAPI) (*control.Controller, error) {
|
||||
@@ -2593,7 +2593,7 @@ func TestCurrencyRefreshFailureKeepsCurrentController(t *testing.T) {
|
||||
t.Cleanup(oldCtrl.Close)
|
||||
m := newTestChatTUI()
|
||||
m.ctrl = oldCtrl
|
||||
m.modelRef = "deepseek-flash/deepseek-v4-flash"
|
||||
m.modelRef = "deepseek-flash/deepseek-flash"
|
||||
m.runtimeProfile = "full"
|
||||
m.buildController = func(controllerBuildSpec, []provider.Message, string, control.SessionAPI) (*control.Controller, error) {
|
||||
return nil, errors.New("build failed")
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
func TestWebSlashRequestsFrontendHandoff(t *testing.T) {
|
||||
m := newTestChatTUI()
|
||||
m.modelRef = "deepseek/deepseek-v4-flash"
|
||||
m.modelRef = "deepseek/deepseek-flash"
|
||||
ctrl := control.New(control.Options{SessionDir: testenv.TempDir(t)})
|
||||
ctrl.EnsureSessionPath()
|
||||
t.Cleanup(ctrl.Close)
|
||||
|
||||
+2
-2
@@ -1268,7 +1268,7 @@ func interactiveSetup(configPath, envPath string) int {
|
||||
return 1
|
||||
}
|
||||
session.setLanguage(lang)
|
||||
session.applyDeepSeekOfficialDefaultPricing()
|
||||
session.applyOfficialDefaultPricing()
|
||||
session.resetProviderSummaryBaseline()
|
||||
i18n.DetectLanguage(lang)
|
||||
|
||||
@@ -1758,7 +1758,7 @@ func withBuiltinFamiliesForLanguage(providers []config.ProviderEntry, pricingLan
|
||||
}
|
||||
defaults := config.Default()
|
||||
defaults.Language = pricingLanguage
|
||||
defaults.ApplyDeepSeekOfficialDefaultPricing()
|
||||
defaults.ApplyOfficialDefaultPricing()
|
||||
for _, bp := range defaults.Providers {
|
||||
if !haveName[bp.Name] {
|
||||
providers = append(providers, bp)
|
||||
|
||||
@@ -2097,7 +2097,7 @@ func TestWithBuiltinFamiliesForLanguageUsesDeepSeekPricing(t *testing.T) {
|
||||
if flash == nil {
|
||||
t.Fatal("deepseek-flash provider missing")
|
||||
}
|
||||
if flash.Price == nil || flash.Price.Output != 0.66 || flash.Price.Currency != "$" {
|
||||
if flash.Price == nil || flash.Price.Output != 0.6 || flash.Price.Currency != "$" {
|
||||
t.Fatalf("flash price = %+v, want frozen USD official table", flash.Price)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ func TestNewChatTUIKeepsExplicitKeylessControllerModel(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const explicit = "deepseek-flash/deepseek-v4-flash"
|
||||
const explicit = "deepseek-flash/deepseek-flash"
|
||||
ctrl, err := setupProfile(context.Background(), explicit, 0, false, event.Discard, "balanced", "")
|
||||
if err != nil {
|
||||
t.Fatalf("interactive build should remain reachable for missing-key recovery: %v", err)
|
||||
@@ -225,7 +225,7 @@ func TestNewChatTUIKeepsExplicitKeylessControllerModel(t *testing.T) {
|
||||
// The official source loads as one folded entry, so the ref resolves under
|
||||
// the canonical name. The model asked for is what has to survive here — the
|
||||
// global default must not have won.
|
||||
const resolved = "deepseek/deepseek-v4-flash"
|
||||
const resolved = "deepseek/deepseek-flash"
|
||||
m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
|
||||
if got := m.modelRef; got != resolved {
|
||||
t.Fatalf("TUI modelRef = %q, want explicit controller model %q (from %q)", got, resolved, explicit)
|
||||
|
||||
@@ -93,13 +93,13 @@ func TestPersistModelWritesDefaultModel(t *testing.T) {
|
||||
t.Setenv("MIMO_API_KEY", "")
|
||||
|
||||
m := newTestChatTUI()
|
||||
m.persistModel("deepseek-flash/deepseek-v4-flash")
|
||||
m.persistModel("deepseek-flash/deepseek-flash")
|
||||
|
||||
body, err := os.ReadFile(config.UserConfigPath())
|
||||
if err != nil {
|
||||
t.Fatalf("read saved config: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(body), `default_model = "deepseek-flash/deepseek-v4-flash"`) {
|
||||
if !strings.Contains(string(body), `default_model = "deepseek-flash/deepseek-flash"`) {
|
||||
t.Fatalf("saved config missing default_model ref:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func quote(usd, cny string) billing.CostQuote {
|
||||
Selected: &selected, BillingMode: "payg", Estimated: true,
|
||||
CostComplete: true, DisplayComplete: true, Complete: true,
|
||||
DisplayStatus: "matched", AggregateMode: "single_currency",
|
||||
ModelRef: "deepseek/deepseek-v4-flash", UsageSource: "executor",
|
||||
ModelRef: "deepseek/deepseek-flash", UsageSource: "executor",
|
||||
PricingFingerprint: "5f35aab018a3af3b", CatalogSource: "https://example.test/pricing",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func TestCLIHotRebuildPathsKeepSessionTemp(t *testing.T) {
|
||||
|
||||
m := newTestChatTUI()
|
||||
m.ctrl = oldCtrl
|
||||
m.modelRef = "deepseek-flash/deepseek-v4-flash"
|
||||
m.modelRef = "deepseek-flash/deepseek-flash"
|
||||
m.runtimeProfile = boot.TokenModeFull
|
||||
m.buildController = func(_ controllerBuildSpec, _ []provider.Message, _ string, outgoing control.SessionAPI) (*control.Controller, error) {
|
||||
return control.New(control.Options{
|
||||
|
||||
@@ -136,12 +136,12 @@ func (s *providerSetupSession) setLanguage(language string) {
|
||||
s.cfg.Language = language
|
||||
}
|
||||
|
||||
func (s *providerSetupSession) applyDeepSeekOfficialDefaultPricing() {
|
||||
func (s *providerSetupSession) applyOfficialDefaultPricing() {
|
||||
before := make(map[string]config.ProviderEntry, len(s.cfg.Providers))
|
||||
for _, provider := range s.cfg.Providers {
|
||||
before[provider.Name] = provider
|
||||
}
|
||||
s.cfg.ApplyDeepSeekOfficialDefaultPricing()
|
||||
s.cfg.ApplyOfficialDefaultPricing()
|
||||
for i := range s.cfg.Providers {
|
||||
after := s.cfg.Providers[i]
|
||||
previous, existed := before[after.Name]
|
||||
|
||||
@@ -294,7 +294,7 @@ func TestRefreshEffortStatusUsesCurrentModel(t *testing.T) {
|
||||
|
||||
ctrl := control.New(control.Options{})
|
||||
m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
|
||||
m.modelRef = "deepseek-flash/deepseek-v4-flash"
|
||||
m.modelRef = "deepseek-flash/deepseek-flash"
|
||||
m.refreshEffortStatus()
|
||||
if m.effortLevel != "auto" {
|
||||
t.Fatalf("effortLevel = %q, want auto", m.effortLevel)
|
||||
|
||||
@@ -34,7 +34,7 @@ func chatTUIWithRunningBackgroundJob(t *testing.T) chatTUI {
|
||||
})
|
||||
m := newTestChatTUI()
|
||||
m.ctrl = ctrl
|
||||
m.modelRef = "deepseek-flash/deepseek-v4-flash"
|
||||
m.modelRef = "deepseek-flash/deepseek-flash"
|
||||
m.runtimeProfile = "full"
|
||||
m.buildController = func(controllerBuildSpec, []provider.Message, string, control.SessionAPI) (*control.Controller, error) {
|
||||
t.Fatal("runtime switch built a replacement while a background job was running")
|
||||
@@ -236,7 +236,7 @@ func TestModelSwitchCarriesRecoveryPathAfterSnapshotConflict(t *testing.T) {
|
||||
return control.New(control.Options{Label: "deepseek-flash"}), nil
|
||||
}
|
||||
|
||||
m.runModelSubcommand("/model deepseek-flash/deepseek-v4-flash")
|
||||
m.runModelSubcommand("/model deepseek-flash/deepseek-flash")
|
||||
if m.pendingModelSwitch == nil {
|
||||
t.Fatal("runModelSubcommand did not queue a model switch")
|
||||
}
|
||||
@@ -259,7 +259,7 @@ func TestEffortSwitchCarriesRecoveryPathAfterSnapshotConflict(t *testing.T) {
|
||||
|
||||
m := newTestChatTUI()
|
||||
m.ctrl = divergedSessionController(t, dir, originalPath)
|
||||
m.modelRef = "deepseek-flash/deepseek-v4-flash"
|
||||
m.modelRef = "deepseek-flash/deepseek-flash"
|
||||
var gotResumePath string
|
||||
m.buildController = func(_ controllerBuildSpec, _ []provider.Message, resumePath string, _ control.SessionAPI) (*control.Controller, error) {
|
||||
gotResumePath = resumePath
|
||||
@@ -288,7 +288,7 @@ func TestSkillRefreshCarriesRecoveryPathAfterSnapshotConflict(t *testing.T) {
|
||||
|
||||
m := newTestChatTUI()
|
||||
m.ctrl = divergedSessionController(t, dir, originalPath)
|
||||
m.modelRef = "deepseek-flash/deepseek-v4-flash"
|
||||
m.modelRef = "deepseek-flash/deepseek-flash"
|
||||
var gotResumePath string
|
||||
m.buildController = func(_ controllerBuildSpec, _ []provider.Message, resumePath string, _ control.SessionAPI) (*control.Controller, error) {
|
||||
gotResumePath = resumePath
|
||||
@@ -315,7 +315,7 @@ func TestWorkModeSwitchUpdatesInPlaceWithoutRebuildOrLeaseMove(t *testing.T) {
|
||||
m := newTestChatTUI()
|
||||
oldCtrl := divergedSessionController(t, dir, originalPath)
|
||||
m.ctrl = oldCtrl
|
||||
m.modelRef = "deepseek-flash/deepseek-v4-flash"
|
||||
m.modelRef = "deepseek-flash/deepseek-flash"
|
||||
m.runtimeProfile = "full"
|
||||
m.leases = control.NewSessionLeaseKeeper()
|
||||
t.Cleanup(m.leases.Release)
|
||||
@@ -531,7 +531,7 @@ func TestModelSwitchFailureKeepsLeaseOnRecoveryPathAfterSnapshotConflict(t *test
|
||||
t.Fatalf("seed active lease: %v", err)
|
||||
}
|
||||
|
||||
m.runModelSubcommand("/model deepseek-flash/deepseek-v4-flash")
|
||||
m.runModelSubcommand("/model deepseek-flash/deepseek-flash")
|
||||
if m.pendingModelSwitch == nil {
|
||||
t.Fatal("runModelSubcommand did not queue a model switch")
|
||||
}
|
||||
@@ -570,7 +570,7 @@ func TestModelSwitchMovesLeaseToRecoveryPathBeforeRebuild(t *testing.T) {
|
||||
return control.New(control.Options{Label: "deepseek-flash"}), nil
|
||||
}
|
||||
|
||||
m.runModelSubcommand("/model deepseek-flash/deepseek-v4-flash")
|
||||
m.runModelSubcommand("/model deepseek-flash/deepseek-flash")
|
||||
if m.pendingModelSwitch == nil {
|
||||
t.Fatal("runModelSubcommand did not queue a model switch")
|
||||
}
|
||||
@@ -588,7 +588,7 @@ func TestEffortSwitchMovesLeaseToRecoveryPathBeforeRebuild(t *testing.T) {
|
||||
|
||||
m := newTestChatTUI()
|
||||
m.ctrl = divergedSessionController(t, dir, active)
|
||||
m.modelRef = "deepseek-flash/deepseek-v4-flash"
|
||||
m.modelRef = "deepseek-flash/deepseek-flash"
|
||||
m.leases = control.NewSessionLeaseKeeper()
|
||||
t.Cleanup(m.leases.Release)
|
||||
if err := m.leases.Rebind(active); err != nil {
|
||||
@@ -617,7 +617,7 @@ func TestSkillRefreshMovesLeaseToRecoveryPathBeforeRebuild(t *testing.T) {
|
||||
|
||||
m := newTestChatTUI()
|
||||
m.ctrl = divergedSessionController(t, dir, active)
|
||||
m.modelRef = "deepseek-flash/deepseek-v4-flash"
|
||||
m.modelRef = "deepseek-flash/deepseek-flash"
|
||||
m.leases = control.NewSessionLeaseKeeper()
|
||||
t.Cleanup(m.leases.Release)
|
||||
if err := m.leases.Rebind(active); err != nil {
|
||||
|
||||
@@ -167,8 +167,8 @@ func TestLaunchWebBrowserInvokesOpenerWithResolvedURL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWebHandoffArgsResumeSameSessionOnDeterministicPort(t *testing.T) {
|
||||
got := webHandoffArgs("/tmp/session.jsonl", "session", "deepseek/deepseek-v4-flash", "delivery")
|
||||
want := "--resume /tmp/session.jsonl --model deepseek/deepseek-v4-flash --profile delivery"
|
||||
got := webHandoffArgs("/tmp/session.jsonl", "session", "deepseek/deepseek-flash", "delivery")
|
||||
want := "--resume /tmp/session.jsonl --model deepseek/deepseek-flash --profile delivery"
|
||||
if strings.Join(got, " ") != want {
|
||||
t.Fatalf("webHandoffArgs() = %q, want %q", strings.Join(got, " "), want)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestBackfillDeepSeekProUsesConfiguredLanguage(t *testing.T) {
|
||||
// Language no longer selects list-price tables; backfill uses USD official
|
||||
// defaults unless the sibling provider freezes a billing_currency.
|
||||
c := &Config{Language: "zh", Providers: []ProviderEntry{
|
||||
{Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY", BillingCurrency: "CNY", Price: deepSeekV4FlashPriceCNY()},
|
||||
{Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY", BillingCurrency: "CNY", Price: deepSeekOfficialRate(DeepSeekFlashModel, "CNY")},
|
||||
}}
|
||||
backfillDeepSeekPro(c)
|
||||
pro := hasModel(c, "deepseek-v4-pro")
|
||||
@@ -102,8 +102,8 @@ func TestNormalizeLegacyProviderModelsRepairsOfficialProvider(t *testing.T) {
|
||||
APIKeyEnv: "DEEPSEEK_API_KEY",
|
||||
}}}
|
||||
normalizeLegacyProviderModels(c)
|
||||
if got := c.Providers[0].Model; got != "deepseek-v4-flash" {
|
||||
t.Fatalf("deepseek-flash model = %q, want deepseek-v4-flash", got)
|
||||
if got := c.Providers[0].Model; got != DeepSeekFlashModel {
|
||||
t.Fatalf("deepseek-flash model = %q, want %q", got, DeepSeekFlashModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ func TestLoadAndSavePreserveStepFunRegionalBaseURLs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLegacyLongCatContextWindowsMigratesOnlyUntouchedOfficialPresets(t *testing.T) {
|
||||
func TestShippedPresetUpgradeLongCatContextWindowsMigratesOnlyUntouchedOfficialPresets(t *testing.T) {
|
||||
c := &Config{Providers: []ProviderEntry{
|
||||
{
|
||||
Name: "longcat-openai",
|
||||
@@ -273,7 +273,7 @@ func TestNormalizeLegacyLongCatContextWindowsMigratesOnlyUntouchedOfficialPreset
|
||||
},
|
||||
}}
|
||||
|
||||
if !normalizeLegacyLongCatContextWindows(c) {
|
||||
if !upgradeShippedPresets(c) {
|
||||
t.Fatal("legacy LongCat context-window migration did not report a change")
|
||||
}
|
||||
if got := c.Providers[0].ContextWindow; got != longCat20ContextWindow {
|
||||
@@ -323,7 +323,7 @@ func TestLoadForEditAppliesLegacyLongCatContextWindowMigration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLegacyQwenContextWindowsMigratesOnlyOfficialPresets(t *testing.T) {
|
||||
func TestShippedPresetUpgradeQwenContextWindowsMigratesOnlyOfficialPresets(t *testing.T) {
|
||||
qwenIDs := []string{
|
||||
"qwen-cn",
|
||||
"qwen-global",
|
||||
@@ -371,7 +371,7 @@ func TestNormalizeLegacyQwenContextWindowsMigratesOnlyOfficialPresets(t *testing
|
||||
}
|
||||
c.Providers = append(c.Providers, customWindow, customEndpoint, customCatalog, customOverride)
|
||||
|
||||
if !normalizeLegacyQwenContextWindows(c) {
|
||||
if !upgradeShippedPresets(c) {
|
||||
t.Fatal("legacy Qwen context-window migration did not report a change")
|
||||
}
|
||||
wantOverrides := qwenModelContextOverrides()
|
||||
@@ -455,7 +455,7 @@ func TestLoadForEditAppliesLegacyQwenContextWindowMigration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLegacyOpenCodeGoKimiK3CatalogMigratesOnlyUntouchedPreset(t *testing.T) {
|
||||
func TestShippedPresetUpgradeOpenCodeGoCatalogMigratesOnlyUntouchedPreset(t *testing.T) {
|
||||
legacyEntry := ProviderEntry{
|
||||
Name: "opencode-go",
|
||||
Kind: "openai",
|
||||
@@ -487,7 +487,7 @@ func TestNormalizeLegacyOpenCodeGoKimiK3CatalogMigratesOnlyUntouchedPreset(t *te
|
||||
noPresetIdentity.ModelOverrides = cloneModelOverrideMap(legacyEntry.ModelOverrides)
|
||||
c := &Config{Providers: []ProviderEntry{legacyEntry, customModels, customEndpoint, noPresetIdentity}}
|
||||
|
||||
if !normalizeLegacyOpenCodeGoKimiK3Catalog(c) {
|
||||
if !upgradeShippedPresets(c) {
|
||||
t.Fatal("legacy OpenCode Go catalog migration did not report a change")
|
||||
}
|
||||
got := &c.Providers[0]
|
||||
@@ -517,7 +517,7 @@ func TestNormalizeLegacyOpenCodeGoKimiK3CatalogMigratesOnlyUntouchedPreset(t *te
|
||||
preIdentity.PresetID = ""
|
||||
preIdentity.ModelOverrides = cloneModelOverrideMap(legacyEntry.ModelOverrides)
|
||||
preIdentityConfig := &Config{Providers: []ProviderEntry{preIdentity}}
|
||||
if !normalizeLegacyOpenCodeGoKimiK3Catalog(preIdentityConfig) || !preIdentityConfig.Providers[0].HasModel("kimi-k3") {
|
||||
if !upgradeShippedPresets(preIdentityConfig) || !preIdentityConfig.Providers[0].HasModel("kimi-k3") {
|
||||
t.Fatal("pre-preset-identity OpenCode Go install was not migrated")
|
||||
}
|
||||
|
||||
@@ -534,7 +534,7 @@ func TestNormalizeLegacyOpenCodeGoKimiK3CatalogMigratesOnlyUntouchedPreset(t *te
|
||||
}
|
||||
customK3.ModelOverrides["KIMI-K3"] = wantK3
|
||||
customK3Config := &Config{Providers: []ProviderEntry{customK3}}
|
||||
if !normalizeLegacyOpenCodeGoKimiK3Catalog(customK3Config) {
|
||||
if !upgradeShippedPresets(customK3Config) {
|
||||
t.Fatal("legacy OpenCode Go catalog with custom Kimi K3 override was not migrated")
|
||||
}
|
||||
gotK3, ok := customK3Config.Providers[0].ModelOverrides["KIMI-K3"]
|
||||
@@ -546,7 +546,7 @@ func TestNormalizeLegacyOpenCodeGoKimiK3CatalogMigratesOnlyUntouchedPreset(t *te
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLegacyKimiK3CatalogMigratesOnlyOfficialUntouchedPresets(t *testing.T) {
|
||||
func TestShippedPresetUpgradeKimiK3CatalogMigratesOnlyOfficialUntouchedPresets(t *testing.T) {
|
||||
legacyEntry := func(id, baseURL string) ProviderEntry {
|
||||
return ProviderEntry{
|
||||
Name: id,
|
||||
@@ -573,7 +573,7 @@ func TestNormalizeLegacyKimiK3CatalogMigratesOnlyOfficialUntouchedPresets(t *tes
|
||||
selectedModel.Model = "kimi-k2.7-code"
|
||||
c := &Config{Providers: []ProviderEntry{cn, global, customModels, customEndpoint, selectedModel}}
|
||||
|
||||
if !normalizeLegacyKimiK3Catalog(c) {
|
||||
if !upgradeShippedPresets(c) {
|
||||
t.Fatal("legacy Kimi direct catalogs did not report a change")
|
||||
}
|
||||
for i := range 2 {
|
||||
@@ -608,7 +608,7 @@ func TestNormalizeLegacyKimiK3CatalogMigratesOnlyOfficialUntouchedPresets(t *tes
|
||||
}
|
||||
customK3.ModelOverrides = map[string]ProviderModelOverride{"KIMI-K3": wantK3}
|
||||
customConfig := &Config{Providers: []ProviderEntry{customK3}}
|
||||
if !normalizeLegacyKimiK3Catalog(customConfig) {
|
||||
if !upgradeShippedPresets(customConfig) {
|
||||
t.Fatal("legacy Kimi catalog with custom K3 override was not migrated")
|
||||
}
|
||||
gotK3, ok := customConfig.Providers[0].ModelOverrides["KIMI-K3"]
|
||||
@@ -620,7 +620,7 @@ func TestNormalizeLegacyKimiK3CatalogMigratesOnlyOfficialUntouchedPresets(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLegacyKimiK3CatalogPreservesVisionChoices(t *testing.T) {
|
||||
func TestShippedPresetUpgradeKimiK3CatalogPreservesVisionChoices(t *testing.T) {
|
||||
base := ProviderEntry{
|
||||
Name: "kimi-cn",
|
||||
Kind: "openai",
|
||||
@@ -635,7 +635,7 @@ func TestNormalizeLegacyKimiK3CatalogPreservesVisionChoices(t *testing.T) {
|
||||
custom.VisionModels = []string{"kimi-k2.5"}
|
||||
c := &Config{Providers: []ProviderEntry{base, custom}}
|
||||
|
||||
if !normalizeLegacyKimiK3Catalog(c) {
|
||||
if !upgradeShippedPresets(c) {
|
||||
t.Fatal("legacy Kimi catalog migration did not report a change")
|
||||
}
|
||||
if got := c.Providers[0].VisionModels; got == nil || len(got) != 0 {
|
||||
@@ -735,7 +735,7 @@ func TestLoadForEditKeepsLegacyOpenCodeGoKimiK3CatalogMigrationInMemoryUntilSave
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLegacyOpenCodeGoKimiK3CatalogPreservesVisionChoices(t *testing.T) {
|
||||
func TestShippedPresetUpgradeOpenCodeGoCatalogPreservesVisionChoices(t *testing.T) {
|
||||
base := ProviderEntry{
|
||||
Name: "opencode-go",
|
||||
Kind: "openai",
|
||||
@@ -758,7 +758,7 @@ func TestNormalizeLegacyOpenCodeGoKimiK3CatalogPreservesVisionChoices(t *testing
|
||||
entry := base
|
||||
entry.VisionModels = tt.vision
|
||||
c := &Config{Providers: []ProviderEntry{entry}}
|
||||
if !normalizeLegacyOpenCodeGoKimiK3Catalog(c) {
|
||||
if !upgradeShippedPresets(c) {
|
||||
t.Fatal("legacy OpenCode Go catalog migration did not report a change")
|
||||
}
|
||||
if got := c.Providers[0].VisionModels; !reflect.DeepEqual(got, tt.want) {
|
||||
@@ -1035,10 +1035,10 @@ func TestNormalizeOfficialDeepSeekModelsRepairsCanonicalProvider(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("deepseek provider missing")
|
||||
}
|
||||
if !p.HasModel("deepseek-v4-flash") || !p.HasModel("deepseek-v4-pro") || !p.HasModel("glm-5") {
|
||||
if !p.HasModel(DeepSeekFlashModel) || !p.HasModel(deepSeekProModel) || !p.HasModel("glm-5") {
|
||||
t.Fatalf("deepseek models = %+v, want official models plus existing model", p.ModelList())
|
||||
}
|
||||
if c.DefaultModel != "deepseek/deepseek-v4-flash" {
|
||||
if c.DefaultModel != "deepseek/"+DeepSeekFlashModel {
|
||||
t.Fatalf("default_model = %q, want retargeted official ref", c.DefaultModel)
|
||||
}
|
||||
if _, ok := c.ResolveModel(c.DefaultModel); !ok {
|
||||
@@ -1142,7 +1142,7 @@ func TestBackfillDeepSeekOfficialPrices(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("deepseek provider missing")
|
||||
}
|
||||
if p.Prices["deepseek-v4-flash"].Output != 0.66 || p.Prices["deepseek-v4-flash"].Currency != "$" || p.Prices["deepseek-v4-pro"].Output != 1.98 || p.Prices["deepseek-v4-pro"].Currency != "$" {
|
||||
if p.Prices["deepseek-v4-flash"].Output != 0.6 || p.Prices["deepseek-v4-flash"].Currency != "$" || p.Prices["deepseek-v4-pro"].Output != 1.98 || p.Prices["deepseek-v4-pro"].Currency != "$" {
|
||||
t.Fatalf("deepseek prices = %+v, want default USD flash/pro prices", p.Prices)
|
||||
}
|
||||
}
|
||||
@@ -1161,7 +1161,7 @@ func TestBackfillDeepSeekOfficialPricesUsesConfiguredLanguage(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("deepseek provider missing")
|
||||
}
|
||||
if p.Prices["deepseek-v4-flash"].Output != 0.66 || p.Prices["deepseek-v4-flash"].Currency != "$" || p.Prices["deepseek-v4-pro"].Output != 1.98 || p.Prices["deepseek-v4-pro"].Currency != "$" {
|
||||
if p.Prices["deepseek-v4-flash"].Output != 0.6 || p.Prices["deepseek-v4-flash"].Currency != "$" || p.Prices["deepseek-v4-pro"].Output != 1.98 || p.Prices["deepseek-v4-pro"].Currency != "$" {
|
||||
t.Fatalf("deepseek prices = %+v, want USD official flash/pro prices", p.Prices)
|
||||
}
|
||||
}
|
||||
@@ -1203,16 +1203,16 @@ func TestBackfillDeepSeekOfficialPricesKeepsProviderWidePrice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDeepSeekOfficialDefaultPricingUsesConfiguredLanguage(t *testing.T) {
|
||||
func TestApplyOfficialDefaultPricingUsesConfiguredLanguage(t *testing.T) {
|
||||
// Language must not rewrite frozen billing_currency list prices.
|
||||
c := Default()
|
||||
c.Language = "zh"
|
||||
applyDeepSeekOfficialDefaultPricing(c)
|
||||
applyOfficialDefaultPricing(c)
|
||||
flash, ok := c.Provider("deepseek-flash")
|
||||
if !ok {
|
||||
t.Fatal("deepseek-flash provider missing")
|
||||
}
|
||||
if flash.Price == nil || flash.Price.Output != 0.66 || flash.Price.Currency != "$" {
|
||||
if flash.Price == nil || flash.Price.Output != 0.6 || flash.Price.Currency != "$" {
|
||||
t.Fatalf("flash price = %+v, want frozen USD default table", flash.Price)
|
||||
}
|
||||
pro, ok := c.Provider("deepseek-pro")
|
||||
@@ -1224,7 +1224,7 @@ func TestApplyDeepSeekOfficialDefaultPricingUsesConfiguredLanguage(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDeepSeekOfficialDefaultPricingKeepsCustomPrice(t *testing.T) {
|
||||
func TestApplyOfficialDefaultPricingKeepsCustomPrice(t *testing.T) {
|
||||
c := &Config{Language: "zh", Providers: []ProviderEntry{{
|
||||
Name: "deepseek-flash",
|
||||
Kind: "openai",
|
||||
@@ -1232,7 +1232,7 @@ func TestApplyDeepSeekOfficialDefaultPricingKeepsCustomPrice(t *testing.T) {
|
||||
Model: "deepseek-v4-flash",
|
||||
Price: &provider.Pricing{CacheHit: 9, Input: 9, Output: 9, Currency: "$"},
|
||||
}}}
|
||||
applyDeepSeekOfficialDefaultPricing(c)
|
||||
applyOfficialDefaultPricing(c)
|
||||
p, ok := c.Provider("deepseek-flash")
|
||||
if !ok {
|
||||
t.Fatal("deepseek-flash provider missing")
|
||||
@@ -1251,18 +1251,18 @@ name = "deepseek-flash"
|
||||
kind = "openai"
|
||||
base_url = "https://api.deepseek.com"
|
||||
model = "deepseek-v4-flash"
|
||||
price = { cache_hit = 0.007, input = 0.22, output = 0.66, currency = "$" }
|
||||
price = { cache_hit = 0.003, input = 0.15, output = 0.6, currency = "$" }
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
c := LoadForEdit(path)
|
||||
flash, ok := c.ResolveModel("deepseek-flash/deepseek-v4-flash")
|
||||
flash, ok := c.ResolveModel("deepseek-flash/" + DeepSeekFlashModel)
|
||||
if !ok {
|
||||
t.Fatal("official flash model missing")
|
||||
}
|
||||
if flash.Price == nil || flash.Price.Output != 0.66 || flash.Price.Currency != "$" {
|
||||
if flash.Price == nil || flash.Price.Output != 0.6 || flash.Price.Currency != "$" {
|
||||
t.Fatalf("flash price = %+v, want persisted USD preset", flash.Price)
|
||||
}
|
||||
|
||||
@@ -1270,7 +1270,7 @@ price = { cache_hit = 0.007, input = 0.22, output = 0.66, currency = "$" }
|
||||
t.Fatalf("SetDesktopCurrency CNY: %v", err)
|
||||
}
|
||||
// Display currency switch must freeze list prices (USD official table stays).
|
||||
if after, _ := c.ResolveModel("deepseek-flash/deepseek-v4-flash"); after.Price == nil || after.Price.Output != 0.66 || after.Price.Currency != "$" {
|
||||
if after, _ := c.ResolveModel("deepseek-flash/" + DeepSeekFlashModel); after.Price == nil || after.Price.Output != 0.6 || after.Price.Currency != "$" {
|
||||
t.Fatalf("flash price = %+v, want frozen USD official table", after.Price)
|
||||
}
|
||||
if got := c.DisplayCurrencyPref(); got != "CNY" {
|
||||
@@ -1289,7 +1289,7 @@ name = "deepseek-flash"
|
||||
kind = "openai"
|
||||
base_url = "https://api.deepseek.com"
|
||||
model = "deepseek-v4-flash"
|
||||
price = { cache_hit = 0.007, input = 0.22, output = 0.66, currency = "$" }
|
||||
price = { cache_hit = 0.003, input = 0.15, output = 0.6, currency = "$" }
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1299,11 +1299,11 @@ price = { cache_hit = 0.007, input = 0.22, output = 0.66, currency = "$" }
|
||||
if err != nil {
|
||||
t.Fatalf("LoadForRoot: %v", err)
|
||||
}
|
||||
flash, ok := c.ResolveModel("deepseek-flash/deepseek-v4-flash")
|
||||
flash, ok := c.ResolveModel("deepseek-flash/" + DeepSeekFlashModel)
|
||||
if !ok {
|
||||
t.Fatal("official flash model missing")
|
||||
}
|
||||
if flash.Price == nil || flash.Price.Output != 0.66 || flash.Price.Currency != "$" {
|
||||
if flash.Price == nil || flash.Price.Output != 0.6 || flash.Price.Currency != "$" {
|
||||
t.Fatalf("flash price = %+v, want persisted USD preset", flash.Price)
|
||||
}
|
||||
}
|
||||
@@ -1315,18 +1315,18 @@ name = "deepseek-flash"
|
||||
kind = "openai"
|
||||
base_url = "https://api.deepseek.com"
|
||||
model = "deepseek-v4-flash"
|
||||
price = { cache_hit = 0.007, input = 0.22, output = 0.66, currency = "$" }
|
||||
price = { cache_hit = 0.003, input = 0.15, output = 0.6, currency = "$" }
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
c := LoadForEdit(path)
|
||||
flash, ok := c.ResolveModel("deepseek-flash/deepseek-v4-flash")
|
||||
flash, ok := c.ResolveModel("deepseek-flash/" + DeepSeekFlashModel)
|
||||
if !ok {
|
||||
t.Fatal("official flash model missing")
|
||||
}
|
||||
if flash.Price == nil || flash.Price.Output != 0.66 || flash.Price.Currency != "$" {
|
||||
if flash.Price == nil || flash.Price.Output != 0.6 || flash.Price.Currency != "$" {
|
||||
t.Fatalf("flash price = %+v, want persisted USD preset", flash.Price)
|
||||
}
|
||||
}
|
||||
@@ -1345,7 +1345,7 @@ models = ["deepseek-v4-flash", "deepseek-v4-pro"]
|
||||
default = "deepseek-v4-flash"
|
||||
|
||||
[providers.prices]
|
||||
deepseek-v4-flash = { cache_hit = 0.007, input = 0.22, output = 0.66, currency = "$" }
|
||||
deepseek-v4-flash = { cache_hit = 0.003, input = 0.15, output = 0.6, currency = "$" }
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1422,15 +1422,15 @@ func TestLoadForRootKeepsPricingRegionUserGlobal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDeepSeekOfficialDefaultPricingExplicitCurrencyWins(t *testing.T) {
|
||||
func TestApplyOfficialDefaultPricingExplicitCurrencyWins(t *testing.T) {
|
||||
c := Default()
|
||||
c.Desktop.Language = "zh"
|
||||
c.Desktop.Currency = "USD"
|
||||
// Display currency no longer selects list-price tables; billing_currency does.
|
||||
flash, _ := c.Provider("deepseek-flash")
|
||||
flash.BillingCurrency = "USD"
|
||||
applyDeepSeekOfficialDefaultPricing(c)
|
||||
if flash.Price == nil || flash.Price.Output != 0.66 || flash.Price.Currency != "$" {
|
||||
applyOfficialDefaultPricing(c)
|
||||
if flash.Price == nil || flash.Price.Output != 0.6 || flash.Price.Currency != "$" {
|
||||
t.Fatalf("flash price = %+v, want USD billing_currency table", flash.Price)
|
||||
}
|
||||
|
||||
@@ -1439,9 +1439,9 @@ func TestApplyDeepSeekOfficialDefaultPricingExplicitCurrencyWins(t *testing.T) {
|
||||
flash.BillingCurrency = "CNY"
|
||||
// Only refresh when price still matches an official default in the *new*
|
||||
// billing currency — force official CNY rates for this assertion.
|
||||
flash.Price = deepSeekV4FlashPriceCNY()
|
||||
applyDeepSeekOfficialDefaultPricing(c)
|
||||
if flash.Price == nil || flash.Price.Output != 4.5 || flash.Price.Currency != "¥" {
|
||||
flash.Price = deepSeekOfficialRate(DeepSeekFlashModel, "CNY")
|
||||
applyOfficialDefaultPricing(c)
|
||||
if flash.Price == nil || flash.Price.Output != 4 || flash.Price.Currency != "¥" {
|
||||
t.Fatalf("flash price = %+v, want CNY billing_currency table", flash.Price)
|
||||
}
|
||||
}
|
||||
@@ -1497,7 +1497,7 @@ func TestResetOfficialProviderPricingOnUpgradeRunsOnce(t *testing.T) {
|
||||
if deepseek.Price != nil {
|
||||
t.Fatalf("deepseek provider-wide price = %+v, want nil after reset", deepseek.Price)
|
||||
}
|
||||
if p := deepseek.Prices["deepseek-v4-flash"]; p == nil || p.Currency != "$" || p.Output != 0.66 {
|
||||
if p := deepseek.Prices[DeepSeekFlashModel]; p == nil || p.Currency != "$" || p.Output != 0.6 {
|
||||
t.Fatalf("deepseek flash price = %+v, want USD default", p)
|
||||
}
|
||||
if p := deepseek.Prices["deepseek-v4-pro"]; p == nil || p.Currency != "$" || p.Output != 1.98 {
|
||||
@@ -1511,7 +1511,7 @@ func TestResetOfficialProviderPricingOnUpgradeRunsOnce(t *testing.T) {
|
||||
t.Fatalf("mimo provider-wide price = %+v, want custom price preserved", mimo.Price)
|
||||
}
|
||||
|
||||
deepseek.Prices["deepseek-v4-flash"] = &provider.Pricing{CacheHit: 4, Input: 4, Output: 4, Currency: "$"}
|
||||
deepseek.Prices[DeepSeekFlashModel] = &provider.Pricing{CacheHit: 4, Input: 4, Output: 4, Currency: "$"}
|
||||
if err := got.SaveTo(path); err != nil {
|
||||
t.Fatalf("SaveTo after custom edit: %v", err)
|
||||
}
|
||||
@@ -1527,7 +1527,7 @@ func TestResetOfficialProviderPricingOnUpgradeRunsOnce(t *testing.T) {
|
||||
t.Fatalf("decode custom config: %v", err)
|
||||
}
|
||||
deepseek, _ = got.Provider("deepseek")
|
||||
if p := deepseek.Prices["deepseek-v4-flash"]; p == nil || p.Output != 4 || p.Currency != "$" {
|
||||
if p := deepseek.Prices[DeepSeekFlashModel]; p == nil || p.Output != 4 || p.Currency != "$" {
|
||||
t.Fatalf("post-upgrade custom flash price = %+v, want preserved", p)
|
||||
}
|
||||
}
|
||||
@@ -1567,7 +1567,7 @@ func TestApplyUserConfigUpgradesOnStartupVersion3NonWindowsAdvancesToV5(t *testi
|
||||
t.Fatalf("config_version = %d, want %d", got.ConfigVersion, Default().ConfigVersion)
|
||||
}
|
||||
deepseek, _ := got.Provider("deepseek")
|
||||
if p := deepseek.Prices["deepseek-v4-flash"]; p == nil || p.Output != 4 || p.Currency != "$" {
|
||||
if p := deepseek.Prices[DeepSeekFlashModel]; p == nil || p.Output != 4 || p.Currency != "$" {
|
||||
t.Fatalf("custom flash price = %+v, want preserved", p)
|
||||
}
|
||||
}
|
||||
@@ -1700,7 +1700,7 @@ func TestResolveModelUsesPerModelPricing(t *testing.T) {
|
||||
Default: "deepseek-v4-flash",
|
||||
Price: &provider.Pricing{CacheHit: 9, Input: 9, Output: 9, Currency: "$"},
|
||||
Prices: map[string]*provider.Pricing{
|
||||
"deepseek-v4-flash": &provider.Pricing{CacheHit: 0.05, Input: 1.5, Output: 4.5, Currency: "¥"},
|
||||
"deepseek-v4-flash": &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 4, Currency: "¥"},
|
||||
"deepseek-v4-pro": &provider.Pricing{CacheHit: 0.15, Input: 4.5, Output: 13.5, Currency: "¥"},
|
||||
},
|
||||
}}}
|
||||
@@ -1715,7 +1715,7 @@ func TestResolveModelUsesPerModelPricing(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("deepseek default did not resolve")
|
||||
}
|
||||
if flash.Price == nil || flash.Price.Output != 4.5 {
|
||||
if flash.Price == nil || flash.Price.Output != 4 {
|
||||
t.Fatalf("flash price = %+v, want model-specific flash price", flash.Price)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestCustomPriceProtectedFromCatalog(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestQuoteForUsageUsesSelectedDisplay(t *testing.T) {
|
||||
price := deepSeekV4FlashPriceUSD()
|
||||
price := deepSeekOfficialRate(DeepSeekFlashModel, "USD")
|
||||
q := QuoteForUsage(price, nil, "USD", "m", "executor", billing.BillingModePAYG, "")
|
||||
if q.Complete {
|
||||
t.Fatal("nil usage should be incomplete")
|
||||
|
||||
@@ -22,7 +22,7 @@ name = "deepseek-flash"
|
||||
kind = "openai"
|
||||
base_url = "https://api.deepseek.com"
|
||||
model = "deepseek-v4-flash"
|
||||
price = { cache_hit = 0.007, input = 0.22, output = 0.66, currency = "$" }
|
||||
price = { cache_hit = 0.003, input = 0.15, output = 0.6, currency = "$" }
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -49,7 +49,7 @@ price = { cache_hit = 0.007, input = 0.22, output = 0.66, currency = "$" }
|
||||
if got := cfg.DisplayCurrencyPref(); got != "CNY" {
|
||||
t.Fatalf("display pref = %q", got)
|
||||
}
|
||||
flash, ok := cfg.ResolveModel("deepseek-flash/deepseek-v4-flash")
|
||||
flash, ok := cfg.ResolveModel("deepseek-flash/" + DeepSeekFlashModel)
|
||||
if !ok {
|
||||
t.Fatal("missing flash")
|
||||
}
|
||||
|
||||
@@ -340,19 +340,6 @@ func normalizeThemeStyle(style string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeDesktopLayoutStyle(style string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(style)) {
|
||||
case "classic":
|
||||
return "classic"
|
||||
case "workbench", "workspace":
|
||||
return "workbench"
|
||||
case "creation":
|
||||
return "creation"
|
||||
default:
|
||||
return "workbench"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCloseBehavior(mode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "quit", "exit":
|
||||
@@ -428,15 +415,6 @@ func (c *Config) DesktopTerminalTheme() string {
|
||||
}
|
||||
}
|
||||
|
||||
// DesktopLayoutStyle normalizes the desktop layout style. New installs default
|
||||
// to workbench; explicit classic remains respected.
|
||||
func (c *Config) DesktopLayoutStyle() string {
|
||||
if strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), "workbench") && strings.TrimSpace(c.Desktop.LayoutStyle) == "" {
|
||||
return "workbench"
|
||||
}
|
||||
return normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)
|
||||
}
|
||||
|
||||
// DesktopCloseBehavior normalizes the desktop close-window preference. It falls
|
||||
// back to the legacy ui.close_behavior value for configs written before [desktop]
|
||||
// existed.
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
package config
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strings"
|
||||
|
||||
// The vendor added an image-taking model to an endpoint we already ship. A
|
||||
// stored model list cannot learn that by itself, so an installed user would see
|
||||
// nothing change on upgrade and would have to know the name to type it in.
|
||||
"reasonix/internal/billing"
|
||||
)
|
||||
|
||||
// DeepSeekVisionModel is the official DeepSeek model that reads images. Named
|
||||
// here because three decisions need the same string: what to backfill, what a
|
||||
// fresh install ships, and which model the image notice may point at.
|
||||
const DeepSeekVisionModel = "deepseek-v4-flash-vision-exp"
|
||||
// The vendor folded its Flash names into one model that reads images, so a
|
||||
// stored config keeps offering a name whose model is gone, beside a vision
|
||||
// connection that has become a duplicate of the flash one.
|
||||
|
||||
// legacyDeepSeekV4Models is the list shipped before that model existed. A entry
|
||||
// still carrying exactly it has not been curated, which is the only case this
|
||||
// may touch — the same test migrateKimiK3VisionModels makes.
|
||||
var legacyDeepSeekV4Models = []string{"deepseek-v4-flash", "deepseek-v4-pro"}
|
||||
// DeepSeekFlashModel is the model behind every Flash name the endpoint accepts.
|
||||
// It reads images, which is why nothing here ships a second entry for that: what
|
||||
// a fresh install gets, what a retired name migrates to, and the model an image
|
||||
// notice may point at are all this one string.
|
||||
const DeepSeekFlashModel = "deepseek-flash"
|
||||
|
||||
// deepSeekProModel kept its own name, rates and text-only reach past that fold.
|
||||
const deepSeekProModel = "deepseek-v4-pro"
|
||||
|
||||
// retiredDeepSeekFlashModels still answer, and answer as DeepSeekFlashModel at
|
||||
// its price — measured 2026-09-13, including the image read the first of them
|
||||
// never had. Migrated rather than kept: the reply echoes the name it was asked
|
||||
// by, so a list carrying one of these reports a model nobody serves.
|
||||
var retiredDeepSeekFlashModels = billing.RetiredDeepSeekFlashModels()
|
||||
|
||||
// retiredDeepSeekVisionProvider was the connection shipped when reading images
|
||||
// needed a model of its own. It folds into the flash entry, which now does that.
|
||||
const retiredDeepSeekVisionProvider = "deepseek-vision"
|
||||
|
||||
// deepSeekDefaultProviders is what a fresh install ships. Anthropic-compatible
|
||||
// Messages, so provider-executed web search is on by default; existing explicit
|
||||
@@ -23,36 +36,21 @@ func deepSeekDefaultProviders() []ProviderEntry {
|
||||
return []ProviderEntry{
|
||||
{
|
||||
Name: "deepseek-flash", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
|
||||
Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY",
|
||||
BalanceURL: "https://api.deepseek.com/user/balance", Thinking: "enabled",
|
||||
Model: DeepSeekFlashModel, APIKeyEnv: "DEEPSEEK_API_KEY",
|
||||
VisionModels: []string{DeepSeekFlashModel},
|
||||
BalanceURL: "https://api.deepseek.com/user/balance", Thinking: "enabled",
|
||||
WebSearch: new(true), SupportedEfforts: []string{"disabled", "low", "high", "max"}, DefaultEffort: "low",
|
||||
ContextWindow: 1_000_000, Price: deepSeekV4FlashPriceUSD(),
|
||||
ContextWindow: 1_000_000, Price: deepSeekOfficialRate(DeepSeekFlashModel, "USD"),
|
||||
BillingCurrency: "USD", BillingMode: "payg",
|
||||
},
|
||||
{
|
||||
Name: "deepseek-pro", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
|
||||
Model: "deepseek-v4-pro", APIKeyEnv: "DEEPSEEK_API_KEY",
|
||||
Model: deepSeekProModel, APIKeyEnv: "DEEPSEEK_API_KEY",
|
||||
BalanceURL: "https://api.deepseek.com/user/balance", Thinking: "enabled",
|
||||
WebSearch: new(true), SupportedEfforts: []string{"disabled", "high", "max"}, DefaultEffort: "high",
|
||||
ContextWindow: 1_000_000, Price: deepSeekV4ProPriceUSD(),
|
||||
ContextWindow: 1_000_000, Price: deepSeekOfficialRate(deepSeekProModel, "USD"),
|
||||
BillingCurrency: "USD", BillingMode: "payg",
|
||||
},
|
||||
deepSeekVisionProvider(),
|
||||
}
|
||||
}
|
||||
|
||||
// deepSeekVisionProvider is the connection a fresh install ships for it. Ticked,
|
||||
// because this is the one model on the host that reads images and an unticked
|
||||
// one drops them; no web-search claim, because nothing has established it.
|
||||
func deepSeekVisionProvider() ProviderEntry {
|
||||
return ProviderEntry{
|
||||
Name: "deepseek-vision", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
|
||||
Model: DeepSeekVisionModel, APIKeyEnv: "DEEPSEEK_API_KEY",
|
||||
VisionModels: []string{DeepSeekVisionModel},
|
||||
BalanceURL: "https://api.deepseek.com/user/balance", Thinking: "enabled",
|
||||
SupportedEfforts: []string{"disabled", "low", "high", "max"}, DefaultEffort: "high",
|
||||
ContextWindow: 1_000_000, Price: deepSeekV4FlashPriceUSD(),
|
||||
BillingCurrency: "USD", BillingMode: "payg",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,55 +67,53 @@ func normalizeOfficialDeepSeekModels(c *Config) bool {
|
||||
if officialProviderHost(p.BaseURL) != "api.deepseek.com" {
|
||||
continue
|
||||
}
|
||||
changed = applyDeepSeekVisionCatalog(p) || changed
|
||||
changed = migrateRetiredDeepSeekFlashModels(p) || changed
|
||||
switch strings.TrimSpace(p.Name) {
|
||||
case "deepseek":
|
||||
required := []string{"deepseek-v4-flash", "deepseek-v4-pro"}
|
||||
required := []string{DeepSeekFlashModel, deepSeekProModel}
|
||||
if strings.EqualFold(strings.TrimSpace(p.Kind), "responses") {
|
||||
required = required[:1]
|
||||
}
|
||||
ensureProviderModels(p, required, "deepseek-v4-flash")
|
||||
case "deepseek-flash":
|
||||
ensureProviderModels(p, []string{"deepseek-v4-flash"}, "deepseek-v4-flash")
|
||||
ensureProviderModels(p, required, DeepSeekFlashModel)
|
||||
case "deepseek-flash", retiredDeepSeekVisionProvider:
|
||||
ensureProviderModels(p, []string{DeepSeekFlashModel}, DeepSeekFlashModel)
|
||||
case "deepseek-pro":
|
||||
ensureProviderModels(p, []string{"deepseek-v4-pro"}, "deepseek-v4-pro")
|
||||
ensureProviderModels(p, []string{deepSeekProModel}, deepSeekProModel)
|
||||
}
|
||||
changed = tickDeepSeekFlashVision(p) || changed
|
||||
backfillDeepSeekAnthropicCapabilities(p)
|
||||
}
|
||||
// Each call must run: ORing them the other way round short-circuits the
|
||||
// fold as soon as an entry above has already reported a change.
|
||||
changed = migrateRetiredDeepSeekModelRefs(c) || changed
|
||||
changed = foldRetiredDeepSeekVisionProvider(c) || changed
|
||||
return changed
|
||||
}
|
||||
|
||||
// applyDeepSeekVisionCatalog offers the image-taking model to an official
|
||||
// DeepSeek entry that still carries the shipped list. It reports whether it
|
||||
// changed anything, so a load-for-edit persists it and a plain load does not.
|
||||
func applyDeepSeekVisionCatalog(p *ProviderEntry) bool {
|
||||
// migrateRetiredDeepSeekFlashModels points one official entry at the model the
|
||||
// endpoint serves for it. Every per-model map is re-keyed too: a price or an
|
||||
// effort list left under a retired name stops answering for the model that
|
||||
// replaced it, which is how an edited rate silently reverts to the default.
|
||||
func migrateRetiredDeepSeekFlashModels(p *ProviderEntry) bool {
|
||||
if p == nil || officialProviderHost(p.BaseURL) != "api.deepseek.com" {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
// Studio may learn the model from a live probe or the user may type its
|
||||
// documented ID into a curated list. In either case the official catalog is
|
||||
// enough to mark a listed model without requiring a second manual checkbox.
|
||||
if p.HasModel(DeepSeekVisionModel) && len(p.VisionModels) == 0 {
|
||||
p.VisionModels = []string{DeepSeekVisionModel}
|
||||
changed = true
|
||||
for _, retired := range retiredDeepSeekFlashModels {
|
||||
changed = renameProviderModel(p, retired, DeepSeekFlashModel) || changed
|
||||
}
|
||||
if !stringSlicesEqual(p.ModelList(), legacyDeepSeekV4Models) {
|
||||
return changed
|
||||
}
|
||||
p.Models = append(append([]string(nil), legacyDeepSeekV4Models...), DeepSeekVisionModel)
|
||||
// Listed but unticked is the worst of both: the model is the one thing on
|
||||
// this endpoint that reads images, and picking it would silently drop them.
|
||||
p.VisionModels = migrateDeepSeekVisionModels(p.VisionModels)
|
||||
return true
|
||||
return changed
|
||||
}
|
||||
|
||||
// migrateDeepSeekVisionModels ticks the new model unless the user has said
|
||||
// something about vision here. An empty list is what the renderer writes for
|
||||
// "nothing to say", so it counts as unsaid; a non-empty one is a choice.
|
||||
func migrateDeepSeekVisionModels(current []string) []string {
|
||||
if len(current) > 0 {
|
||||
return current
|
||||
// tickDeepSeekFlashVision marks the flash model as image-taking unless the user
|
||||
// has said something about vision on this entry. Listed but unticked is the
|
||||
// worst of both: pro takes an image and answers as if it saw nothing rather
|
||||
// than refusing it, so an unticked flash would drop images just as quietly.
|
||||
func tickDeepSeekFlashVision(p *ProviderEntry) bool {
|
||||
if p == nil || officialProviderHost(p.BaseURL) != "api.deepseek.com" ||
|
||||
len(p.VisionModels) > 0 || !p.HasModel(DeepSeekFlashModel) {
|
||||
return false
|
||||
}
|
||||
return []string{DeepSeekVisionModel}
|
||||
p.VisionModels = []string{DeepSeekFlashModel}
|
||||
return true
|
||||
}
|
||||
@@ -3,14 +3,15 @@ package config
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
// An installed user's model list is frozen in their config file, so a model the
|
||||
// vendor added afterwards reaches them only if something puts it there. The
|
||||
// bound on that is the same one every catalog migration takes: extend the entry
|
||||
// that still carries what we shipped, and otherwise only label a model already
|
||||
// present by its exact vendor-documented ID.
|
||||
func TestDeepSeekVisionCatalogOnlyTouchesTheShippedList(t *testing.T) {
|
||||
// An installed user's model list is frozen in their config file. The vendor
|
||||
// retired the names in it into one model that reads images, and nothing reaches
|
||||
// that user unless the load puts it there — while a relay serving the same
|
||||
// names is a different endpoint making its own promises.
|
||||
func TestRetiredDeepSeekFlashModelsMigrate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
entry ProviderEntry
|
||||
@@ -19,52 +20,40 @@ func TestDeepSeekVisionCatalogOnlyTouchesTheShippedList(t *testing.T) {
|
||||
vision []string
|
||||
}{
|
||||
{
|
||||
name: "the list we shipped gains the model, ticked",
|
||||
name: "the list we shipped points at the model that serves it",
|
||||
entry: ProviderEntry{
|
||||
Name: "deepseek", Kind: "openai", BaseURL: "https://api.deepseek.com",
|
||||
Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
|
||||
},
|
||||
want: true,
|
||||
models: []string{"deepseek-v4-flash", "deepseek-v4-pro", DeepSeekVisionModel},
|
||||
vision: []string{DeepSeekVisionModel},
|
||||
models: []string{DeepSeekFlashModel, deepSeekProModel},
|
||||
},
|
||||
{
|
||||
name: "a curated list is the user's and stays put",
|
||||
name: "the experimental vision name folds into the same model",
|
||||
entry: ProviderEntry{
|
||||
Name: "deepseek-vision", Kind: "anthropic", BaseURL: "https://api.deepseek.com",
|
||||
Models: []string{"deepseek-v4-flash-vision-exp"},
|
||||
},
|
||||
want: true,
|
||||
models: []string{DeepSeekFlashModel},
|
||||
},
|
||||
{
|
||||
name: "an entry carrying both retired names lists the survivor once",
|
||||
entry: ProviderEntry{
|
||||
Name: "deepseek", Kind: "openai", BaseURL: "https://api.deepseek.com",
|
||||
Models: []string{"deepseek-v4-pro"},
|
||||
},
|
||||
models: []string{"deepseek-v4-pro"},
|
||||
},
|
||||
{
|
||||
name: "a probed vision-only list is recognised without another checkbox",
|
||||
entry: ProviderEntry{
|
||||
Name: "deepseek-vision", Kind: "responses", BaseURL: "https://api.deepseek.com",
|
||||
Models: []string{DeepSeekVisionModel},
|
||||
Models: []string{"deepseek-v4-flash", "deepseek-v4-flash-vision-exp", "deepseek-v4-pro"},
|
||||
},
|
||||
want: true,
|
||||
models: []string{DeepSeekVisionModel},
|
||||
vision: []string{DeepSeekVisionModel},
|
||||
models: []string{DeepSeekFlashModel, deepSeekProModel},
|
||||
},
|
||||
{
|
||||
name: "a vision choice already made is not overwritten",
|
||||
name: "a curated list keeps its curation, under the new name",
|
||||
entry: ProviderEntry{
|
||||
Name: "deepseek", Kind: "openai", BaseURL: "https://api.deepseek.com",
|
||||
Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"}, VisionModels: []string{"deepseek-v4-pro"},
|
||||
Models: []string{"deepseek-v4-flash"},
|
||||
},
|
||||
want: true,
|
||||
models: []string{"deepseek-v4-flash", "deepseek-v4-pro", DeepSeekVisionModel},
|
||||
vision: []string{"deepseek-v4-pro"},
|
||||
},
|
||||
{
|
||||
name: "responses accepts the same vision model",
|
||||
entry: ProviderEntry{
|
||||
Name: "deepseek", Kind: "responses", BaseURL: "https://api.deepseek.com",
|
||||
Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
|
||||
},
|
||||
want: true,
|
||||
models: []string{"deepseek-v4-flash", "deepseek-v4-pro", DeepSeekVisionModel},
|
||||
vision: []string{DeepSeekVisionModel},
|
||||
models: []string{DeepSeekFlashModel},
|
||||
},
|
||||
{
|
||||
name: "a relay serving the same names is not this endpoint",
|
||||
@@ -73,18 +62,20 @@ func TestDeepSeekVisionCatalogOnlyTouchesTheShippedList(t *testing.T) {
|
||||
Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
|
||||
},
|
||||
models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
|
||||
vision: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
entry := tc.entry
|
||||
if got := applyDeepSeekVisionCatalog(&entry); got != tc.want {
|
||||
if got := migrateRetiredDeepSeekFlashModels(&entry); got != tc.want {
|
||||
t.Fatalf("changed = %v, want %v", got, tc.want)
|
||||
}
|
||||
if !slices.Equal(entry.ModelList(), tc.models) {
|
||||
t.Fatalf("models = %v, want %v", entry.ModelList(), tc.models)
|
||||
}
|
||||
tickDeepSeekFlashVision(&entry)
|
||||
if tc.vision != nil && !slices.Equal(entry.VisionModels, tc.vision) {
|
||||
t.Fatalf("vision_models = %v, want %v", entry.VisionModels, tc.vision)
|
||||
}
|
||||
@@ -92,26 +83,61 @@ func TestDeepSeekVisionCatalogOnlyTouchesTheShippedList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Running twice must not append twice, because a plain load does not persist
|
||||
// and the next one starts from the same file.
|
||||
func TestDeepSeekVisionCatalogIsIdempotent(t *testing.T) {
|
||||
// A price or an effort list is keyed by model. Left under the retired key it
|
||||
// stops answering for the model that replaced it, which reads to the user as
|
||||
// the rate they typed reverting to the default on its own.
|
||||
func TestRetiredDeepSeekFlashModelsCarryTheirPerModelSettings(t *testing.T) {
|
||||
entry := ProviderEntry{
|
||||
Name: "deepseek", Kind: "openai", BaseURL: "https://api.deepseek.com",
|
||||
Name: "deepseek", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
|
||||
Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
|
||||
Prices: map[string]*provider.Pricing{
|
||||
"deepseek-v4-flash": {CacheHit: 9, Input: 9, Output: 9, Currency: "$"},
|
||||
},
|
||||
ModelOverrides: map[string]ProviderModelOverride{
|
||||
"deepseek-v4-flash": {SupportedEfforts: []string{"disabled", "max"}, DefaultEffort: "max"},
|
||||
},
|
||||
VisionModels: []string{"deepseek-v4-flash"},
|
||||
Default: "deepseek-v4-flash",
|
||||
}
|
||||
applyDeepSeekVisionCatalog(&entry)
|
||||
if applyDeepSeekVisionCatalog(&entry) {
|
||||
t.Fatal("a second pass reported a change; the list would grow every load")
|
||||
if !migrateRetiredDeepSeekFlashModels(&entry) {
|
||||
t.Fatal("nothing migrated")
|
||||
}
|
||||
if got := len(entry.ModelList()); got != 3 {
|
||||
t.Fatalf("models = %v, want three", entry.ModelList())
|
||||
if p := entry.Prices[DeepSeekFlashModel]; p == nil || p.Input != 9 {
|
||||
t.Fatalf("price did not follow the rename: %+v", entry.Prices)
|
||||
}
|
||||
if _, stale := entry.Prices["deepseek-v4-flash"]; stale {
|
||||
t.Fatal("the retired key still holds a price; the model now has two")
|
||||
}
|
||||
if ov := entry.ModelOverrides[DeepSeekFlashModel]; ov.DefaultEffort != "max" {
|
||||
t.Fatalf("override did not follow the rename: %+v", entry.ModelOverrides)
|
||||
}
|
||||
if entry.Default != DeepSeekFlashModel {
|
||||
t.Fatalf("default = %q", entry.Default)
|
||||
}
|
||||
if !slices.Equal(entry.VisionModels, []string{DeepSeekFlashModel}) {
|
||||
t.Fatalf("vision_models = %v", entry.VisionModels)
|
||||
}
|
||||
}
|
||||
|
||||
// The protocol upgrade allow-lists the models it shipped. Backfilling a third
|
||||
// one must not read as curation, or the migration would strand exactly the
|
||||
// users it just reached.
|
||||
func TestVisionBackfillKeepsTheProtocolUpgradeAvailable(t *testing.T) {
|
||||
// Running twice must not change anything the second time: a plain load does not
|
||||
// persist, so the next one starts from the same file.
|
||||
func TestDeepSeekCatalogMigrationIsIdempotent(t *testing.T) {
|
||||
c := &Config{Providers: []ProviderEntry{{
|
||||
Name: "deepseek", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
|
||||
Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
|
||||
}}}
|
||||
normalizeOfficialDeepSeekModels(c)
|
||||
if normalizeOfficialDeepSeekModels(c) {
|
||||
t.Fatal("a second pass reported a change; the file would be rewritten every load")
|
||||
}
|
||||
if got := c.Providers[0].ModelList(); !slices.Equal(got, []string{DeepSeekFlashModel, deepSeekProModel}) {
|
||||
t.Fatalf("models = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The protocol upgrade allow-lists the models it shipped. Migrating them must
|
||||
// not read as curation, or it would strand exactly the users it just reached.
|
||||
func TestMigrationKeepsTheProtocolUpgradeAvailable(t *testing.T) {
|
||||
entry := ProviderEntry{
|
||||
Name: "deepseek", Kind: "openai", BaseURL: "https://api.deepseek.com",
|
||||
APIKeyEnv: "DEEPSEEK_API_KEY", Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
|
||||
@@ -119,9 +145,9 @@ func TestVisionBackfillKeepsTheProtocolUpgradeAvailable(t *testing.T) {
|
||||
if !CanUpgradeDeepSeekProviderProtocol(&entry) {
|
||||
t.Fatal("fixture does not start upgradable")
|
||||
}
|
||||
applyDeepSeekVisionCatalog(&entry)
|
||||
migrateRetiredDeepSeekFlashModels(&entry)
|
||||
if !CanUpgradeDeepSeekProviderProtocol(&entry) {
|
||||
t.Fatal("the backfill froze this entry out of the protocol upgrade")
|
||||
t.Fatal("the migration froze this entry out of the protocol upgrade")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,19 +156,19 @@ func TestVisionBackfillKeepsTheProtocolUpgradeAvailable(t *testing.T) {
|
||||
// is how a dropped picture reads as a delivered one.
|
||||
func TestFirstVisionModelRefFindsAReaderOrSaysNone(t *testing.T) {
|
||||
none := &Config{Providers: []ProviderEntry{{
|
||||
Name: "deepseek", Kind: "openai", BaseURL: "https://api.deepseek.com",
|
||||
Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
|
||||
Name: "deepseek-pro", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
|
||||
Models: []string{deepSeekProModel},
|
||||
}}}
|
||||
if got := none.FirstVisionModelRef(); got != "" {
|
||||
t.Fatalf("text-only config offered %q", got)
|
||||
}
|
||||
|
||||
withVision := &Config{Providers: []ProviderEntry{{
|
||||
Name: "deepseek", Kind: "openai", BaseURL: "https://api.deepseek.com",
|
||||
Models: []string{"deepseek-v4-flash", "deepseek-v4-pro", DeepSeekVisionModel},
|
||||
VisionModels: []string{DeepSeekVisionModel},
|
||||
Name: "deepseek", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
|
||||
Models: []string{DeepSeekFlashModel, deepSeekProModel},
|
||||
VisionModels: []string{DeepSeekFlashModel},
|
||||
}}}
|
||||
if got := withVision.FirstVisionModelRef(); got != "deepseek/"+DeepSeekVisionModel {
|
||||
if got := withVision.FirstVisionModelRef(); got != "deepseek/"+DeepSeekFlashModel {
|
||||
t.Fatalf("offered %q, want the image-taking model", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// deepseek_presets.go — the curated connections for DeepSeek's own endpoints.
|
||||
package config
|
||||
|
||||
// deepSeekOfficialModels is what the official endpoint serves today; the Responses
|
||||
// shape carries flash alone, which is the only model it documents.
|
||||
var (
|
||||
deepSeekOfficialModels = []string{DeepSeekFlashModel, deepSeekProModel}
|
||||
deepSeekResponsesModels = []string{DeepSeekFlashModel}
|
||||
)
|
||||
|
||||
func deepSeekAnthropicPreset() ProviderPreset {
|
||||
return ProviderPreset{
|
||||
ID: "deepseek-anthropic",
|
||||
Label: "DeepSeek Official Anthropic",
|
||||
Description: "Separate official DeepSeek Anthropic-compatible entry for Flash and Pro.",
|
||||
KeyEnv: "DEEPSEEK_API_KEY",
|
||||
Entries: []ProviderEntry{{
|
||||
Name: "deepseek-anthropic",
|
||||
Kind: "anthropic",
|
||||
BaseURL: deepSeekAnthropicBaseURL,
|
||||
Models: deepSeekOfficialModels,
|
||||
Default: DeepSeekFlashModel,
|
||||
VisionModels: []string{DeepSeekFlashModel},
|
||||
APIKeyEnv: "DEEPSEEK_API_KEY",
|
||||
BalanceURL: "https://api.deepseek.com/user/balance",
|
||||
Thinking: "enabled",
|
||||
WebSearch: new(true),
|
||||
ContextWindow: 1_000_000,
|
||||
Prices: deepSeekOfficialPricesUSD(),
|
||||
ModelOverrides: map[string]ProviderModelOverride{
|
||||
DeepSeekFlashModel: {SupportedEfforts: []string{"disabled", "low", "high", "max"}, DefaultEffort: "high"},
|
||||
deepSeekProModel: {SupportedEfforts: []string{"disabled", "high", "max"}, DefaultEffort: "high"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// Images ride input_image parts here, measured 2026-09-13 against the endpoint.
|
||||
func deepSeekResponsesPreset() ProviderPreset {
|
||||
return ProviderPreset{
|
||||
ID: "deepseek-responses",
|
||||
Label: "DeepSeek Official Responses API",
|
||||
Description: "Official stateless DeepSeek Responses API for Flash with server-side web search.",
|
||||
KeyEnv: "DEEPSEEK_API_KEY",
|
||||
Entries: []ProviderEntry{{
|
||||
Name: "deepseek-responses",
|
||||
Kind: "responses",
|
||||
BaseURL: "https://api.deepseek.com",
|
||||
Models: deepSeekResponsesModels,
|
||||
Default: DeepSeekFlashModel,
|
||||
VisionModels: []string{DeepSeekFlashModel},
|
||||
APIKeyEnv: "DEEPSEEK_API_KEY",
|
||||
BalanceURL: "https://api.deepseek.com/user/balance",
|
||||
ContextWindow: 1_000_000,
|
||||
Price: deepSeekOfficialRate(DeepSeekFlashModel, "USD"),
|
||||
ResponsesMode: "stateless",
|
||||
WebSearch: new(true),
|
||||
SupportedEfforts: []string{"low", "high", "max"},
|
||||
DefaultEffort: "high",
|
||||
}},
|
||||
}
|
||||
}
|
||||
@@ -88,20 +88,18 @@ func CanUpgradeDeepSeekProviderProtocol(p *ProviderEntry) bool {
|
||||
models := p.ModelList()
|
||||
switch strings.TrimSpace(p.Name) {
|
||||
case "deepseek-flash":
|
||||
return len(models) == 1 && strings.TrimSpace(models[0]) == "deepseek-v4-flash"
|
||||
return len(models) == 1 && isShippedDeepSeekFlashModel(models[0])
|
||||
case "deepseek-pro":
|
||||
return len(models) == 1 && strings.TrimSpace(models[0]) == "deepseek-v4-pro"
|
||||
return len(models) == 1 && strings.TrimSpace(models[0]) == deepSeekProModel
|
||||
case "deepseek":
|
||||
if len(models) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, model := range models {
|
||||
switch strings.TrimSpace(model) {
|
||||
// The vision model counts as shipped, not as curation: the catalog
|
||||
// backfill puts it here, so treating it as a user edit would freeze
|
||||
// everyone it reached out of this upgrade.
|
||||
case "deepseek-v4-flash", "deepseek-v4-pro", DeepSeekVisionModel:
|
||||
default:
|
||||
// A retired flash name counts as shipped, not as curation: it is
|
||||
// what the catalog put there, so reading it as a user edit would
|
||||
// freeze everyone it reached out of this upgrade.
|
||||
if !isShippedDeepSeekFlashModel(model) && strings.TrimSpace(model) != deepSeekProModel {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -111,6 +109,13 @@ func CanUpgradeDeepSeekProviderProtocol(p *ProviderEntry) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// isShippedDeepSeekFlashModel accepts the current flash name and the retired
|
||||
// ones, because this runs on configs from either side of that migration.
|
||||
func isShippedDeepSeekFlashModel(model string) bool {
|
||||
model = strings.TrimSpace(model)
|
||||
return model == DeepSeekFlashModel || slices.Contains(retiredDeepSeekFlashModels, model)
|
||||
}
|
||||
|
||||
func editLegacyDeepSeekProtocolFile(path, target string, automatic bool) (bool, error) {
|
||||
unlock, err := LockConfigFileEdits(path)
|
||||
if err != nil {
|
||||
@@ -244,7 +249,7 @@ func isUnmodifiedLegacyDeepSeekProvider(p ProviderEntry, raw map[string]any) boo
|
||||
if p.ContextWindow != 0 && p.ContextWindow != 1_000_000 {
|
||||
return false
|
||||
}
|
||||
return p.Price == nil || IsKnownDeepSeekOfficialPricing(p.Model, p.Price)
|
||||
return p.Price == nil || IsKnownOfficialPricing("deepseek", p.Model, p.Price)
|
||||
}
|
||||
|
||||
func deepSeekUpgradeTargetMatches(target, providerName string) bool {
|
||||
|
||||
@@ -86,7 +86,7 @@ future_provider_field = "untouched"
|
||||
}
|
||||
// Both models now live under the one folded entry, and per-model efforts
|
||||
// ride its overrides — so this asks what each model actually resolves to.
|
||||
for _, model := range []string{"deepseek-v4-flash", "deepseek-v4-pro"} {
|
||||
for _, model := range []string{DeepSeekFlashModel, deepSeekProModel} {
|
||||
entry, ok := cfg.ResolveModel("deepseek/" + model)
|
||||
if !ok {
|
||||
t.Fatalf("migrated model %q missing", model)
|
||||
@@ -621,10 +621,10 @@ func TestNormalizeOfficialDeepSeekModelsDoesNotAddProToResponses(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("DeepSeek provider missing after normalization")
|
||||
}
|
||||
if !p.HasModel("deepseek-v4-flash") {
|
||||
if !p.HasModel(DeepSeekFlashModel) {
|
||||
t.Fatal("Responses provider lost its Flash model")
|
||||
}
|
||||
if p.HasModel("deepseek-v4-pro") {
|
||||
if p.HasModel(deepSeekProModel) {
|
||||
t.Fatalf("Responses normalization added unsupported Pro model: %+v", p.ModelList())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// deepseek_vision_fold.go — retiring the connection that existed only to reach
|
||||
// an image-taking model, now that the flash model beside it reads images itself.
|
||||
package config
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// foldRetiredDeepSeekVisionProvider drops that connection once the entry beside
|
||||
// it reaches the same model on the same key. Anything the user configured on it
|
||||
// keeps it: folding would answer their edit by deleting where it lived.
|
||||
func foldRetiredDeepSeekVisionProvider(c *Config) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
vision, ok := c.Provider(retiredDeepSeekVisionProvider)
|
||||
if !ok || !isFoldableDeepSeekVisionEntry(vision) {
|
||||
return false
|
||||
}
|
||||
survivor := deepSeekFlashPeerName(c, vision)
|
||||
if survivor == "" {
|
||||
return false
|
||||
}
|
||||
kept := make([]ProviderEntry, 0, len(c.Providers))
|
||||
for _, p := range c.Providers {
|
||||
if p.Name != retiredDeepSeekVisionProvider {
|
||||
kept = append(kept, p)
|
||||
}
|
||||
}
|
||||
c.Providers = kept
|
||||
drop := map[string]bool{retiredDeepSeekVisionProvider: true}
|
||||
foldProviderAccessInto(c, drop, survivor)
|
||||
retargetModelRefs(c, func(ref string) string {
|
||||
prov, model, hasModel := strings.Cut(ref, "/")
|
||||
if prov != retiredDeepSeekVisionProvider {
|
||||
return ref
|
||||
}
|
||||
if !hasModel || strings.TrimSpace(model) == "" {
|
||||
return survivor
|
||||
}
|
||||
return survivor + "/" + model
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// isFoldableDeepSeekVisionEntry reports whether the entry is still the one that
|
||||
// shipped: the official endpoint, the flash model alone, and no configuration of
|
||||
// the user's own. Per-model maps count as theirs — they only exist once edited.
|
||||
func isFoldableDeepSeekVisionEntry(p *ProviderEntry) bool {
|
||||
return p != nil &&
|
||||
officialProviderHost(p.BaseURL) == "api.deepseek.com" &&
|
||||
slices.Equal(p.ModelList(), []string{DeepSeekFlashModel}) &&
|
||||
len(p.Prices) == 0 && len(p.ModelOverrides) == 0 &&
|
||||
len(p.Headers) == 0 && len(p.ExtraBody) == 0
|
||||
}
|
||||
|
||||
// deepSeekFlashPeerName is the entry that keeps reaching the model after the
|
||||
// fold. Same host and same key: another account's entry serving the same model
|
||||
// is not a route this one's callers already have.
|
||||
func deepSeekFlashPeerName(c *Config, vision *ProviderEntry) string {
|
||||
for i := range c.Providers {
|
||||
p := &c.Providers[i]
|
||||
if p.Name == retiredDeepSeekVisionProvider ||
|
||||
officialProviderHost(p.BaseURL) != "api.deepseek.com" ||
|
||||
p.APIKeyEnv != vision.APIKeyEnv ||
|
||||
!p.HasModel(DeepSeekFlashModel) {
|
||||
continue
|
||||
}
|
||||
return p.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// migrateRetiredDeepSeekModels renames the retired flash models everywhere one
|
||||
// load can still see them under their own provider — the entries that list them
|
||||
// and the refs that name them. Both have to happen before a fold renames the
|
||||
// provider, which is what a ref is matched against.
|
||||
func migrateRetiredDeepSeekModels(c *Config) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
for i := range c.Providers {
|
||||
changed = migrateRetiredDeepSeekFlashModels(&c.Providers[i]) || changed
|
||||
}
|
||||
return migrateRetiredDeepSeekModelRefs(c) || changed
|
||||
}
|
||||
|
||||
// migrateRetiredDeepSeekModelRefs points a stored ref at the model its provider
|
||||
// now lists. The endpoint still answers a retired name, but the entry no longer
|
||||
// offers one, so the ref would resolve to nothing before it ever reached a wire.
|
||||
func migrateRetiredDeepSeekModelRefs(c *Config) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
retargetModelRefs(c, func(ref string) string {
|
||||
prov, model, hasModel := strings.Cut(ref, "/")
|
||||
if !hasModel || !slices.Contains(retiredDeepSeekFlashModels, strings.TrimSpace(model)) {
|
||||
return ref
|
||||
}
|
||||
if p, ok := c.Provider(prov); !ok || officialProviderHost(p.BaseURL) != "api.deepseek.com" {
|
||||
return ref
|
||||
}
|
||||
changed = true
|
||||
return prov + "/" + DeepSeekFlashModel
|
||||
})
|
||||
return changed
|
||||
}
|
||||
|
||||
// retargetModelRefs applies rewrite to every field holding a provider/model ref.
|
||||
// Missing one leaves a ref pointing at what the others just stopped naming.
|
||||
func retargetModelRefs(c *Config, rewrite func(string) string) {
|
||||
for _, field := range append([]*string{&c.DefaultModel, &c.Bot.Model}, c.roleModelRefTargets()...) {
|
||||
if ref := strings.TrimSpace(*field); ref != "" {
|
||||
*field = rewrite(ref)
|
||||
}
|
||||
}
|
||||
for skill, ref := range c.Agent.SubagentModels {
|
||||
if ref = strings.TrimSpace(ref); ref != "" {
|
||||
c.Agent.SubagentModels[skill] = rewrite(ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/testenv"
|
||||
)
|
||||
|
||||
// The vision connection existed because reading an image needed a model of its
|
||||
// own. It no longer does, so the entry is a second name for what the flash entry
|
||||
// already reaches — and two rows for one model is what a panel shows as two
|
||||
// accounts.
|
||||
func TestRetiredVisionProviderFoldsIntoFlash(t *testing.T) {
|
||||
home := testenv.TempDir(t)
|
||||
ws := testenv.TempDir(t)
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
seed := `config_version = 1
|
||||
default_model = "deepseek-vision/deepseek-v4-flash-vision-exp"
|
||||
|
||||
[[providers]]
|
||||
name = "deepseek-flash"
|
||||
kind = "anthropic"
|
||||
base_url = "https://api.deepseek.com/anthropic"
|
||||
model = "deepseek-v4-flash"
|
||||
api_key_env = "DEEPSEEK_API_KEY"
|
||||
|
||||
[[providers]]
|
||||
name = "deepseek-vision"
|
||||
kind = "anthropic"
|
||||
base_url = "https://api.deepseek.com/anthropic"
|
||||
model = "deepseek-v4-flash-vision-exp"
|
||||
api_key_env = "DEEPSEEK_API_KEY"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte(seed), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := LoadForRootReadOnly(ws)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, still := cfg.Provider(retiredDeepSeekVisionProvider); still {
|
||||
t.Fatal("the vision connection survived the fold; one model now shows as two sources")
|
||||
}
|
||||
entry, ok := cfg.ResolveModel(cfg.DefaultModel)
|
||||
if !ok {
|
||||
t.Fatalf("default_model %q no longer resolves after the fold", cfg.DefaultModel)
|
||||
}
|
||||
if !entry.HasVisionModel(DeepSeekFlashModel) {
|
||||
t.Fatalf("the surviving entry does not read images: %+v", entry.VisionModels)
|
||||
}
|
||||
}
|
||||
|
||||
// Folding an entry the user configured would answer their edit by deleting
|
||||
// where it lived, so anything of their own on it keeps the entry.
|
||||
func TestConfiguredVisionProviderIsNotFolded(t *testing.T) {
|
||||
c := &Config{Providers: []ProviderEntry{
|
||||
{
|
||||
Name: "deepseek-flash", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
|
||||
Model: DeepSeekFlashModel, APIKeyEnv: "DEEPSEEK_API_KEY",
|
||||
},
|
||||
{
|
||||
Name: retiredDeepSeekVisionProvider, Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
|
||||
Model: DeepSeekFlashModel, APIKeyEnv: "DEEPSEEK_API_KEY",
|
||||
Prices: map[string]*provider.Pricing{DeepSeekFlashModel: {Input: 9, Output: 9, Currency: "$"}},
|
||||
},
|
||||
}}
|
||||
if foldRetiredDeepSeekVisionProvider(c) {
|
||||
t.Fatal("an entry carrying the user's own price was folded away")
|
||||
}
|
||||
if _, ok := c.Provider(retiredDeepSeekVisionProvider); !ok {
|
||||
t.Fatal("the entry is gone")
|
||||
}
|
||||
}
|
||||
|
||||
// A fold needs somewhere for its callers to land. Without a peer on the same
|
||||
// key the entry is the only route to the model, and dropping it removes the
|
||||
// capability rather than deduplicating it.
|
||||
func TestVisionProviderWithoutAPeerIsKept(t *testing.T) {
|
||||
c := &Config{Providers: []ProviderEntry{{
|
||||
Name: retiredDeepSeekVisionProvider, Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
|
||||
Model: DeepSeekFlashModel, APIKeyEnv: "DEEPSEEK_API_KEY",
|
||||
}}}
|
||||
if foldRetiredDeepSeekVisionProvider(c) {
|
||||
t.Fatal("folded the only entry reaching the model")
|
||||
}
|
||||
}
|
||||
@@ -45,12 +45,11 @@ func (c *Config) SetDesktopTray(mode string) error {
|
||||
// separate from top-level language and [ui] so desktop choices do not affect CLI
|
||||
// language, terminal colours, or provider-visible prompt/request data.
|
||||
type DesktopConfig struct {
|
||||
Language string `toml:"language"` // auto|en|zh; empty/auto = browser/OS auto-detect
|
||||
Currency string `toml:"currency"` // legacy display currency; migrated to [billing].display_currency
|
||||
LayoutStyle string `toml:"layout_style"` // classic|workbench|creation; desktop layout style
|
||||
Theme string `toml:"theme"` // auto|dark|light; empty resolves to auto
|
||||
ThemeStyle string `toml:"theme_style"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases
|
||||
ThemePack string `toml:"theme_pack"` // installed pack id; empty is the default appearance
|
||||
Language string `toml:"language"` // auto|en|zh; empty/auto = browser/OS auto-detect
|
||||
Currency string `toml:"currency"` // legacy display currency; migrated to [billing].display_currency
|
||||
Theme string `toml:"theme"` // auto|dark|light; empty resolves to auto
|
||||
ThemeStyle string `toml:"theme_style"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases
|
||||
ThemePack string `toml:"theme_pack"` // installed pack id; empty is the default appearance
|
||||
// Welcomed records that the opening sequence has played. It lives in the
|
||||
// config rather than browser storage because clearing a cache is not the
|
||||
// same as meeting the app for the first time.
|
||||
|
||||
+2
-18
@@ -193,7 +193,7 @@ func (c *Config) SetLanguage(lang string) error {
|
||||
default:
|
||||
return fmt.Errorf("language %q: must be auto|en|zh", lang)
|
||||
}
|
||||
c.ApplyDeepSeekOfficialDefaultPricing()
|
||||
c.ApplyOfficialDefaultPricing()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -226,7 +226,7 @@ func (c *Config) SetDesktopLanguage(lang string) error {
|
||||
default:
|
||||
return fmt.Errorf("desktop language %q: must be auto|en|zh", lang)
|
||||
}
|
||||
c.ApplyDeepSeekOfficialDefaultPricing()
|
||||
c.ApplyOfficialDefaultPricing()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -271,22 +271,6 @@ func (c *Config) SetDesktopTerminalTheme(theme string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDesktopLayoutStyle sets the desktop layout style. UI-only; it must not
|
||||
// affect CLI output or provider-visible request data.
|
||||
func (c *Config) SetDesktopLayoutStyle(style string) error {
|
||||
switch strings.ToLower(strings.TrimSpace(style)) {
|
||||
case "", "classic":
|
||||
c.Desktop.LayoutStyle = "classic"
|
||||
case "workbench", "workspace":
|
||||
c.Desktop.LayoutStyle = "workbench"
|
||||
case "creation":
|
||||
c.Desktop.LayoutStyle = "creation"
|
||||
default:
|
||||
return fmt.Errorf("desktop layout style %q: must be classic|workbench|creation", style)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDesktopExternalOpener stores the stable id selected by the desktop Open
|
||||
// control. Availability is deliberately checked by the native desktop shell,
|
||||
// because config is shared across operating systems and installations.
|
||||
|
||||
@@ -169,9 +169,6 @@ func TestDesktopPreferencesAreSeparateFromCLI(t *testing.T) {
|
||||
if err := c.SetDesktopTerminalTheme("light"); err != nil {
|
||||
t.Fatalf("SetDesktopTerminalTheme: %v", err)
|
||||
}
|
||||
if err := c.SetDesktopLayoutStyle("workbench"); err != nil {
|
||||
t.Fatalf("SetDesktopLayoutStyle: %v", err)
|
||||
}
|
||||
if err := c.SetDesktopStatusBarStyle("text"); err != nil {
|
||||
t.Fatalf("SetDesktopStatusBarStyle: %v", err)
|
||||
}
|
||||
@@ -200,9 +197,6 @@ func TestDesktopPreferencesAreSeparateFromCLI(t *testing.T) {
|
||||
if got := c.DesktopTerminalTheme(); got != "light" {
|
||||
t.Fatalf("desktop terminal theme = %q, want light", got)
|
||||
}
|
||||
if got := c.DesktopLayoutStyle(); got != "workbench" {
|
||||
t.Fatalf("desktop layout style = %q, want workbench", got)
|
||||
}
|
||||
if got := c.DesktopStatusBarStyle(); got != "text" {
|
||||
t.Fatalf("desktop status bar style = %q, want text", got)
|
||||
}
|
||||
@@ -260,42 +254,6 @@ func TestDesktopCurrencyNormalizesAndRefreshesOfficialPricing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopLayoutStyleNormalizes(t *testing.T) {
|
||||
if got := Default().DesktopLayoutStyle(); got != "workbench" {
|
||||
t.Fatalf("default desktop layout style = %q, want workbench", got)
|
||||
}
|
||||
for _, tt := range []struct {
|
||||
in string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"", "classic", false},
|
||||
{"classic", "classic", false},
|
||||
{" workbench ", "workbench", false},
|
||||
{"workspace", "workbench", false},
|
||||
{"creation", "creation", false},
|
||||
{" Creation ", "creation", false},
|
||||
{"later", "workbench", true},
|
||||
} {
|
||||
c := Default()
|
||||
if err := c.SetDesktopLayoutStyle(tt.in); (err != nil) != tt.wantErr {
|
||||
t.Fatalf("SetDesktopLayoutStyle(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr)
|
||||
}
|
||||
if got := c.DesktopLayoutStyle(); got != tt.want {
|
||||
t.Fatalf("DesktopLayoutStyle(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
|
||||
c := Default()
|
||||
c.Desktop.ThemeStyle = "workbench"
|
||||
if got := c.DesktopLayoutStyle(); got != "workbench" {
|
||||
t.Fatalf("legacy desktop theme_style=workbench layout = %q, want workbench", got)
|
||||
}
|
||||
if got := c.DesktopThemeStyle(); got != "" {
|
||||
t.Fatalf("legacy desktop theme_style=workbench theme style = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopConversationWidthNormalizes(t *testing.T) {
|
||||
if got := Default().DesktopConversationWidth(); got != "standard" {
|
||||
t.Fatalf("default desktop conversation width = %q, want standard", got)
|
||||
@@ -3248,3 +3206,14 @@ func TestBrokenProjectConfigSymlinkFailsLoadAndSave(t *testing.T) {
|
||||
t.Fatal("failed operations replaced the broken project config symlink")
|
||||
}
|
||||
}
|
||||
|
||||
// "workbench" was a layout name that briefly lived in theme_style. The layout
|
||||
// setting is retired; what still has to hold is that the value was never a
|
||||
// theme, so a config carrying it renders in the default appearance.
|
||||
func TestDesktopThemeStyleRejectsTheRetiredLayoutName(t *testing.T) {
|
||||
c := Default()
|
||||
c.Desktop.ThemeStyle = "workbench"
|
||||
if got := c.DesktopThemeStyle(); got != "" {
|
||||
t.Fatalf("theme style = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
+21
-19
@@ -51,14 +51,7 @@ var modelReasoningCapabilities = map[string]modelReasoningCapability{
|
||||
// Windows are the ones already stated for these models in the shipped
|
||||
// entries; carrying them by model is what lets a gateway serving the same
|
||||
// model inherit the ceiling instead of resolving to nothing.
|
||||
"deepseek-v4-flash": {
|
||||
Protocol: ReasoningProtocolDeepSeek,
|
||||
VendorHost: "api.deepseek.com",
|
||||
Levels: []string{"disabled", "low", "high", "max"},
|
||||
Default: "high",
|
||||
Aliases: map[string]string{"xhigh": "high"},
|
||||
ContextWindow: 1_000_000,
|
||||
},
|
||||
DeepSeekFlashModel: deepSeekFlashCapability(),
|
||||
"deepseek-v4-pro": {
|
||||
Protocol: ReasoningProtocolDeepSeek,
|
||||
VendorHost: "api.deepseek.com",
|
||||
@@ -66,17 +59,11 @@ var modelReasoningCapabilities = map[string]modelReasoningCapability{
|
||||
Default: "high",
|
||||
ContextWindow: 1_000_000,
|
||||
},
|
||||
// Vision, measured 2026-08-21: the docs say nothing about reasoning, but the
|
||||
// endpoint answers this model exactly as it answers flash — same vocabulary,
|
||||
// same rejections, reasoning_content throughout. So it carries flash's ladder.
|
||||
"deepseek-v4-flash-vision-exp": {
|
||||
Protocol: ReasoningProtocolDeepSeek,
|
||||
VendorHost: "api.deepseek.com",
|
||||
Levels: []string{"disabled", "low", "high", "max"},
|
||||
Default: "high",
|
||||
Aliases: map[string]string{"xhigh": "high"},
|
||||
ContextWindow: 1_000_000,
|
||||
},
|
||||
// The retired flash names are the same model under an old id, so they carry
|
||||
// the same ladder rather than resolving to nothing on a gateway still asked
|
||||
// by one of them.
|
||||
"deepseek-v4-flash": deepSeekFlashCapability(),
|
||||
"deepseek-v4-flash-vision-exp": deepSeekFlashCapability(),
|
||||
// GPT-5.6, measured 2026-08-20: the endpoint refuses "minimal" by naming the
|
||||
// model, though the generic API vocabulary carries it. Keyed by model so a
|
||||
// gateway serving them under its own name inherits the ladder.
|
||||
@@ -85,6 +72,21 @@ var modelReasoningCapabilities = map[string]modelReasoningCapability{
|
||||
"gpt-5.6-terra": gpt56Capability(),
|
||||
}
|
||||
|
||||
// deepSeekFlashCapability is the flash ladder, measured 2026-09-13: the endpoint
|
||||
// names its own enum low|medium|high|xhigh|ultra|max in a 400, rejects minimal
|
||||
// and none outright, and stops thinking only for thinking.type=disabled — so
|
||||
// "disabled" is a level of ours and the aliases carry the rest onto the enum.
|
||||
func deepSeekFlashCapability() modelReasoningCapability {
|
||||
return modelReasoningCapability{
|
||||
Protocol: ReasoningProtocolDeepSeek,
|
||||
VendorHost: "api.deepseek.com",
|
||||
Levels: []string{"disabled", "low", "high", "max"},
|
||||
Default: "high",
|
||||
Aliases: map[string]string{"minimal": "low", "medium": "high", "xhigh": "high", "ultra": "max"},
|
||||
ContextWindow: 1_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
func gpt56Capability() modelReasoningCapability {
|
||||
return modelReasoningCapability{
|
||||
Protocol: ReasoningProtocolOpenAI,
|
||||
|
||||
@@ -4,13 +4,17 @@ package config
|
||||
|
||||
import "strings"
|
||||
|
||||
// normalizeLegacyProviderFields runs every fold. One call keeps the load
|
||||
// pipeline from growing a line per superseded field.
|
||||
func normalizeLegacyProviderFields(c *Config) {
|
||||
// normalizeLegacyProviderFields runs every fold, reporting whether it changed
|
||||
// anything. The rename goes first: a fold keyed by a retired name files the
|
||||
// user's price and window under a key the template also fills, leaving the
|
||||
// rename to choose between two values nothing tells apart.
|
||||
func normalizeLegacyProviderFields(c *Config) bool {
|
||||
changed := migrateRetiredDeepSeekModels(c)
|
||||
normalizeLegacyProviderModels(c)
|
||||
normalizeLegacyResponsesMode(c)
|
||||
canonicalizeOfficialDeepSeekSource(c)
|
||||
foldLegacyDeepSeekPeers(c)
|
||||
return changed
|
||||
}
|
||||
|
||||
// normalizeLegacyResponsesMode folds responses_stateful into responses_mode, the
|
||||
|
||||
+27
-276
@@ -222,17 +222,14 @@ func (r Roots) loadForRoot(root string, migrateOnDisk bool) (*Config, error) {
|
||||
normalizeRetiredAutoPlan(cfg)
|
||||
normalizeLegacyMCPTiers(cfg)
|
||||
normalizeLegacyStepFunBaseURLs(cfg)
|
||||
normalizeLegacyLongCatContextWindows(cfg)
|
||||
normalizeLegacyQwenContextWindows(cfg)
|
||||
normalizeLegacyKimiK3Catalog(cfg)
|
||||
normalizeLegacyOpenCodeGoKimiK3Catalog(cfg)
|
||||
upgradeShippedPresets(cfg)
|
||||
normalizeLegacyMimoCustomProviders(cfg)
|
||||
normalizeLegacyProviderFields(cfg)
|
||||
normalizeDesktopOfficialProviderAccess(cfg)
|
||||
normalizeOfficialDeepSeekModels(cfg)
|
||||
migrateBillingDisplayCurrency(cfg)
|
||||
freezeProviderBillingCurrencies(cfg)
|
||||
applyDeepSeekOfficialDefaultPricing(cfg)
|
||||
applyOfficialDefaultPricing(cfg)
|
||||
backfillDeepSeekOfficialPrices(cfg)
|
||||
normalizeEffortConfig(cfg)
|
||||
backfillDeepSeekPro(cfg)
|
||||
@@ -341,12 +338,12 @@ func ConfigFileDefinesCompactRatio(path string) bool {
|
||||
}
|
||||
|
||||
// backfillDeepSeekPro restores deepseek-pro for configs the pre-fix setup wizard
|
||||
// wrote with only deepseek-v4-flash: a keyless /models probe used to drop the Pro
|
||||
// wrote with only the flash model: a keyless /models probe used to drop the Pro
|
||||
// SKU, leaving users unable to switch to it. In-memory only — the user's file is
|
||||
// untouched. Narrowly scoped to the official DeepSeek endpoint (which is known to
|
||||
// serve pro) so a custom flash-only deployment isn't given an entry that 404s.
|
||||
func backfillDeepSeekPro(c *Config) {
|
||||
const flashModel, proModel = "deepseek-v4-flash", "deepseek-v4-pro"
|
||||
const proModel = deepSeekProModel
|
||||
var flash *ProviderEntry
|
||||
for i := range c.Providers {
|
||||
p := &c.Providers[i]
|
||||
@@ -354,10 +351,10 @@ func backfillDeepSeekPro(c *Config) {
|
||||
return
|
||||
}
|
||||
for _, m := range p.ModelList() {
|
||||
switch m {
|
||||
case proModel:
|
||||
switch {
|
||||
case m == proModel:
|
||||
return // pro already reachable
|
||||
case flashModel:
|
||||
case isShippedDeepSeekFlashModel(m):
|
||||
if officialProviderKind(p) == "deepseek" {
|
||||
flash = p
|
||||
}
|
||||
@@ -385,45 +382,13 @@ func backfillDeepSeekPro(c *Config) {
|
||||
}
|
||||
bp.BillingCurrency = currency
|
||||
bp.persistedOfficialCurrency = currency
|
||||
bp.Price = deepSeekV4PriceForModel(currency, proModel)
|
||||
bp.Price = deepSeekOfficialPriceForModel(currency, proModel)
|
||||
c.Providers = append(c.Providers, bp)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func backfillDeepSeekOfficialPrices(c *Config) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
for i := range c.Providers {
|
||||
p := &c.Providers[i]
|
||||
if officialProviderKind(p) != "deepseek" {
|
||||
continue
|
||||
}
|
||||
backfillDeepSeekOfficialEndpointDefaults(p)
|
||||
currency := p.ProviderBillingCurrency()
|
||||
if currency == "" {
|
||||
currency = p.persistedOfficialCurrency
|
||||
}
|
||||
if currency == "" {
|
||||
currency = "USD"
|
||||
}
|
||||
defaults := DeepSeekV4PricesForCurrency(currency)
|
||||
if p.Price != nil {
|
||||
continue
|
||||
}
|
||||
if p.Prices == nil {
|
||||
p.Prices = map[string]*provider.Pricing{}
|
||||
}
|
||||
for model, price := range defaults {
|
||||
if p.HasModel(model) && p.Prices[model] == nil {
|
||||
p.Prices[model] = clonePricing(price)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// backfillDeepSeekOfficialEndpointDefaults restores the two official-endpoint
|
||||
// fields a config may legitimately omit. Both are safe to infer here precisely
|
||||
// because the caller already matched api.deepseek.com: the wallet endpoint is
|
||||
@@ -775,17 +740,14 @@ func normalizeConfigForEdit(cfg *Config) bool {
|
||||
changed = normalizeRetiredMultiThresholdCompaction(cfg) || changed
|
||||
normalizeLegacyMCPTiers(cfg)
|
||||
changed = normalizeLegacyStepFunBaseURLs(cfg) || changed
|
||||
changed = normalizeLegacyLongCatContextWindows(cfg) || changed
|
||||
changed = normalizeLegacyQwenContextWindows(cfg) || changed
|
||||
changed = normalizeLegacyKimiK3Catalog(cfg) || changed
|
||||
changed = normalizeLegacyOpenCodeGoKimiK3Catalog(cfg) || changed
|
||||
changed = upgradeShippedPresets(cfg) || changed
|
||||
changed = normalizeLegacyMimoCustomProviders(cfg) || changed
|
||||
normalizeLegacyProviderFields(cfg)
|
||||
changed = normalizeLegacyProviderFields(cfg) || changed
|
||||
normalizeDesktopOfficialProviderAccess(cfg)
|
||||
changed = normalizeOfficialDeepSeekModels(cfg) || changed
|
||||
migrateBillingDisplayCurrency(cfg)
|
||||
freezeProviderBillingCurrencies(cfg)
|
||||
applyDeepSeekOfficialDefaultPricing(cfg)
|
||||
applyOfficialDefaultPricing(cfg)
|
||||
backfillDeepSeekOfficialPrices(cfg)
|
||||
normalizeEffortConfig(cfg)
|
||||
return changed
|
||||
@@ -1109,218 +1071,6 @@ func normalizedBaseURLForMigration(raw string) string {
|
||||
return strings.TrimRight(strings.TrimSpace(raw), "/")
|
||||
}
|
||||
|
||||
func normalizeLegacyLongCatContextWindows(c *Config) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
for i := range c.Providers {
|
||||
p := &c.Providers[i]
|
||||
if p.ContextWindow != legacyLongCat20ContextWindow {
|
||||
continue
|
||||
}
|
||||
var kind, baseURL string
|
||||
switch strings.TrimSpace(p.PresetID) {
|
||||
case "longcat-openai":
|
||||
kind, baseURL = "openai", longCatOpenAIBaseURL
|
||||
case "longcat-anthropic":
|
||||
kind, baseURL = "anthropic", longCatAnthropicBaseURL
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(p.Kind), kind) ||
|
||||
normalizedBaseURLForMigration(p.BaseURL) != baseURL ||
|
||||
!stringSlicesEqual(p.Models, longCat20Models) ||
|
||||
p.Model != "" ||
|
||||
p.Default != longCat20Models[0] {
|
||||
continue
|
||||
}
|
||||
p.ContextWindow = longCat20ContextWindow
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// normalizeLegacyQwenContextWindows upgrades only installed official Qwen
|
||||
// presets that still carry the old zero context window and untouched model
|
||||
// catalog. Custom endpoints, catalogs, provider-wide windows, and existing
|
||||
// per-model override values remain user-owned.
|
||||
func normalizeLegacyQwenContextWindows(c *Config) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
for i := range c.Providers {
|
||||
p := &c.Providers[i]
|
||||
if p.ContextWindow != 0 {
|
||||
continue
|
||||
}
|
||||
presetID := qwenPresetIDForMigration(*p)
|
||||
if presetID == "" {
|
||||
continue
|
||||
}
|
||||
preset, ok := CuratedProviderPreset(presetID)
|
||||
if !ok || len(preset.Entries) != 1 {
|
||||
continue
|
||||
}
|
||||
canonical := preset.Entries[0]
|
||||
if !strings.EqualFold(strings.TrimSpace(p.Kind), strings.TrimSpace(canonical.Kind)) ||
|
||||
normalizedBaseURLForMigration(p.BaseURL) != normalizedBaseURLForMigration(canonical.BaseURL) ||
|
||||
!stringSlicesEqual(p.Models, canonical.Models) ||
|
||||
strings.TrimSpace(p.Model) != "" {
|
||||
continue
|
||||
}
|
||||
p.ContextWindow = canonical.ContextWindow
|
||||
mergeMissingQwenContextOverrides(p, canonical.ModelOverrides)
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func qwenPresetIDForMigration(p ProviderEntry) string {
|
||||
presetID := strings.TrimSpace(p.PresetID)
|
||||
if presetID == "" {
|
||||
presetID = strings.TrimSpace(p.Name)
|
||||
}
|
||||
switch presetID {
|
||||
case "qwen-cn",
|
||||
"qwen-global",
|
||||
"qwen-coding-plan-cn",
|
||||
"qwen-coding-plan-cn-anthropic",
|
||||
"qwen-coding-plan-global",
|
||||
"qwen-coding-plan-global-anthropic":
|
||||
return presetID
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func mergeMissingQwenContextOverrides(p *ProviderEntry, defaults map[string]ProviderModelOverride) {
|
||||
if p == nil || len(defaults) == 0 {
|
||||
return
|
||||
}
|
||||
if p.ModelOverrides == nil {
|
||||
p.ModelOverrides = make(map[string]ProviderModelOverride, len(defaults))
|
||||
}
|
||||
for defaultKey, defaultOverride := range defaults {
|
||||
overrideKey := defaultKey
|
||||
for key := range p.ModelOverrides {
|
||||
if strings.EqualFold(strings.TrimSpace(key), defaultKey) {
|
||||
overrideKey = key
|
||||
break
|
||||
}
|
||||
}
|
||||
override := p.ModelOverrides[overrideKey]
|
||||
if override.ContextWindow == 0 {
|
||||
override.ContextWindow = defaultOverride.ContextWindow
|
||||
p.ModelOverrides[overrideKey] = override
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeLegacyKimiK3Catalog upgrades only untouched Kimi direct-API model
|
||||
// catalogs on the official regional endpoints. Custom model lists, endpoints,
|
||||
// defaults, credentials, and provider-wide settings remain user-owned.
|
||||
func normalizeLegacyKimiK3Catalog(c *Config) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
for i := range c.Providers {
|
||||
p := &c.Providers[i]
|
||||
presetID := strings.TrimSpace(p.PresetID)
|
||||
name := strings.TrimSpace(p.Name)
|
||||
var baseURL string
|
||||
switch {
|
||||
case presetID == "kimi-cn" || (presetID == "" && name == "kimi-cn"):
|
||||
baseURL = "https://api.moonshot.cn/v1"
|
||||
case presetID == "kimi-global" || (presetID == "" && name == "kimi-global"):
|
||||
baseURL = "https://api.moonshot.ai/v1"
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(p.Kind), "openai") ||
|
||||
normalizedBaseURLForMigration(p.BaseURL) != baseURL ||
|
||||
!stringSlicesEqual(p.Models, legacyKimiAPIModels) ||
|
||||
strings.TrimSpace(p.Model) != "" {
|
||||
continue
|
||||
}
|
||||
p.Models = append([]string(nil), kimiAPIModels...)
|
||||
p.VisionModels = migrateKimiK3VisionModels(p.VisionModels, legacyKimiAPIModels)
|
||||
mergeMissingKimiK3Override(p, kimiK3DirectOverride())
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// migrateKimiK3VisionModels preserves explicit provider-level vision choices.
|
||||
// A nil list or an exact copy of the old preset list indicates that the user
|
||||
// has not customized vision support and should receive Kimi K3's capability.
|
||||
func migrateKimiK3VisionModels(current, legacy []string) []string {
|
||||
if current != nil && (legacy == nil || !stringSlicesEqual(current, legacy)) {
|
||||
return current
|
||||
}
|
||||
return mergeModelLists([]string{"kimi-k3"}, current)
|
||||
}
|
||||
|
||||
func mergeMissingKimiK3Override(p *ProviderEntry, defaults ProviderModelOverride) {
|
||||
if p.ModelOverrides == nil {
|
||||
p.ModelOverrides = map[string]ProviderModelOverride{}
|
||||
}
|
||||
overrideKey := "kimi-k3"
|
||||
for key := range p.ModelOverrides {
|
||||
if strings.EqualFold(strings.TrimSpace(key), overrideKey) {
|
||||
overrideKey = key
|
||||
break
|
||||
}
|
||||
}
|
||||
kimiK3 := p.ModelOverrides[overrideKey]
|
||||
if strings.TrimSpace(kimiK3.ReasoningProtocol) == "" {
|
||||
kimiK3.ReasoningProtocol = defaults.ReasoningProtocol
|
||||
}
|
||||
if kimiK3.SupportedEfforts == nil {
|
||||
kimiK3.SupportedEfforts = append([]string(nil), defaults.SupportedEfforts...)
|
||||
}
|
||||
if strings.TrimSpace(kimiK3.DefaultEffort) == "" && containsString(normalizedEffortLevels(kimiK3.SupportedEfforts), defaults.DefaultEffort) {
|
||||
kimiK3.DefaultEffort = defaults.DefaultEffort
|
||||
}
|
||||
if kimiK3.ContextWindow <= 0 {
|
||||
kimiK3.ContextWindow = defaults.ContextWindow
|
||||
}
|
||||
p.ModelOverrides[overrideKey] = kimiK3
|
||||
}
|
||||
|
||||
// normalizeLegacyOpenCodeGoKimiK3Catalog upgrades only the untouched model
|
||||
// catalog from the original editable OpenCode Go preset. A user-curated model
|
||||
// list or custom endpoint is left alone, while other provider edits (headers,
|
||||
// key env, provider-wide context) survive the additive K3 capability update.
|
||||
func normalizeLegacyOpenCodeGoKimiK3Catalog(c *Config) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
for i := range c.Providers {
|
||||
p := &c.Providers[i]
|
||||
presetID := strings.TrimSpace(p.PresetID)
|
||||
if (presetID != "opencode-go" && (presetID != "" || strings.TrimSpace(p.Name) != "opencode-go")) ||
|
||||
!strings.EqualFold(strings.TrimSpace(p.Kind), "openai") ||
|
||||
normalizedBaseURLForMigration(p.BaseURL) != "https://opencode.ai/zen/go/v1" ||
|
||||
!stringSlicesEqual(p.Models, legacyOpenCodeGoModels) ||
|
||||
strings.TrimSpace(p.Model) != "" {
|
||||
continue
|
||||
}
|
||||
p.Models = append([]string(nil), opencodeGoModels...)
|
||||
p.VisionModels = migrateKimiK3VisionModels(p.VisionModels, nil)
|
||||
mergeMissingKimiK3Override(p, ProviderModelOverride{
|
||||
ReasoningProtocol: ReasoningProtocolOpenAI,
|
||||
SupportedEfforts: []string{"high", "max"},
|
||||
DefaultEffort: "max",
|
||||
ContextWindow: 1_048_576,
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeLegacyMimoProviderCatalogs(c *Config) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
@@ -1407,8 +1157,8 @@ func backfillDeepSeekAnthropicCapabilities(p *ProviderEntry) {
|
||||
p.Thinking = "enabled"
|
||||
}
|
||||
capabilities := map[string]ProviderModelOverride{
|
||||
"deepseek-v4-flash": {SupportedEfforts: []string{"disabled", "low", "high", "max"}, DefaultEffort: "high"},
|
||||
"deepseek-v4-pro": {SupportedEfforts: []string{"disabled", "high", "max"}, DefaultEffort: "high"},
|
||||
DeepSeekFlashModel: {SupportedEfforts: []string{"disabled", "low", "high", "max"}, DefaultEffort: "high"},
|
||||
deepSeekProModel: {SupportedEfforts: []string{"disabled", "high", "max"}, DefaultEffort: "high"},
|
||||
}
|
||||
if model := strings.TrimSpace(p.Model); model != "" && len(p.Models) == 0 {
|
||||
defaults, ok := capabilities[model]
|
||||
@@ -1473,9 +1223,9 @@ func ensureProviderModels(p *ProviderEntry, required []string, fallbackDefault s
|
||||
func legacyOfficialProviderModel(name string) string {
|
||||
switch strings.TrimSpace(name) {
|
||||
case "deepseek-flash":
|
||||
return "deepseek-v4-flash"
|
||||
return DeepSeekFlashModel
|
||||
case "deepseek-pro":
|
||||
return "deepseek-v4-pro"
|
||||
return deepSeekProModel
|
||||
case "mimo", "xiaomi-mimo", "xiaomi_mimo", "mimo-api", "mimo-token-plan", "mimo-pro":
|
||||
return "mimo-v2.5-pro"
|
||||
case "mimo-flash":
|
||||
@@ -1610,9 +1360,9 @@ func legacyMimoCustomProvider(name string) ProviderEntry {
|
||||
NoProxy: true,
|
||||
}
|
||||
case "mimo-flash":
|
||||
return ProviderEntry{Name: "mimo-flash", Kind: "openai", BaseURL: "https://token-plan-cn.xiaomimimo.com/v1", Model: "mimo-v2.5", APIKeyEnv: "MIMO_API_KEY", ContextWindow: 1_000_000, Price: mimoV25Price(), NoProxy: true}
|
||||
return ProviderEntry{Name: "mimo-flash", Kind: "openai", BaseURL: "https://token-plan-cn.xiaomimimo.com/v1", Model: "mimo-v2.5", APIKeyEnv: "MIMO_API_KEY", ContextWindow: 1_000_000, Price: officialVendorPrice("mimo", "CNY", "mimo-v2.5"), NoProxy: true}
|
||||
default:
|
||||
return ProviderEntry{Name: "mimo-pro", Kind: "openai", BaseURL: "https://token-plan-cn.xiaomimimo.com/v1", Model: "mimo-v2.5-pro", APIKeyEnv: "MIMO_API_KEY", ContextWindow: 1_000_000, Price: mimoV25ProPrice(), NoProxy: true}
|
||||
return ProviderEntry{Name: "mimo-pro", Kind: "openai", BaseURL: "https://token-plan-cn.xiaomimimo.com/v1", Model: "mimo-v2.5-pro", APIKeyEnv: "MIMO_API_KEY", ContextWindow: 1_000_000, Price: officialVendorPrice("mimo", "CNY", "mimo-v2.5-pro"), NoProxy: true}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1781,17 +1531,18 @@ func ensureDeepSeekOfficialProvider(c *Config) {
|
||||
Name: "deepseek",
|
||||
Kind: "anthropic",
|
||||
BaseURL: deepSeekAnthropicBaseURL,
|
||||
Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
|
||||
Default: "deepseek-v4-flash",
|
||||
Models: []string{DeepSeekFlashModel, deepSeekProModel},
|
||||
Default: DeepSeekFlashModel,
|
||||
VisionModels: []string{DeepSeekFlashModel},
|
||||
APIKeyEnv: "DEEPSEEK_API_KEY",
|
||||
BalanceURL: "https://api.deepseek.com/user/balance",
|
||||
Thinking: "enabled",
|
||||
WebSearch: new(true),
|
||||
ContextWindow: 1_000_000,
|
||||
Prices: deepSeekV4PricesForConfig(c),
|
||||
Prices: deepSeekOfficialPricesForConfig(c),
|
||||
ModelOverrides: map[string]ProviderModelOverride{
|
||||
"deepseek-v4-flash": {SupportedEfforts: []string{"disabled", "low", "high", "max"}, DefaultEffort: "high"},
|
||||
"deepseek-v4-pro": {SupportedEfforts: []string{"disabled", "high", "max"}, DefaultEffort: "high"},
|
||||
DeepSeekFlashModel: {SupportedEfforts: []string{"disabled", "low", "high", "max"}, DefaultEffort: "high"},
|
||||
deepSeekProModel: {SupportedEfforts: []string{"disabled", "high", "max"}, DefaultEffort: "high"},
|
||||
},
|
||||
}
|
||||
legacyProviders := officialLegacyDeepSeekProviders(c)
|
||||
@@ -1802,7 +1553,7 @@ func ensureDeepSeekOfficialProvider(c *Config) {
|
||||
currency = legacyProviders[0].persistedOfficialCurrency
|
||||
entry.persistedOfficialCurrency = currency
|
||||
}
|
||||
entry.Prices = DeepSeekV4PricesForCurrency(currency)
|
||||
entry.Prices = DeepSeekOfficialPricesForCurrency(currency)
|
||||
for _, old := range legacyProviders {
|
||||
entry.Models = mergeModelLists(entry.Models, old.ModelList())
|
||||
mergeLegacyDeepSeekModelConfiguration(&entry, old)
|
||||
@@ -2121,7 +1872,7 @@ func preferredLegacyDeepSeekDefault(entries []*ProviderEntry, models []string, f
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return firstKnownModel(fallback, models, "deepseek-v4-flash")
|
||||
return firstKnownModel(fallback, models, DeepSeekFlashModel)
|
||||
}
|
||||
|
||||
func mergeLegacyDeepSeekModelConfiguration(entry, old *ProviderEntry) {
|
||||
@@ -2253,7 +2004,7 @@ func retargetDesktopOfficialRef(ref string, access map[string]bool) string {
|
||||
return ref
|
||||
}
|
||||
if !hasModel || strings.TrimSpace(model) == "" {
|
||||
model = "deepseek-v4-flash"
|
||||
model = DeepSeekFlashModel
|
||||
}
|
||||
return "deepseek/" + model
|
||||
case "deepseek-pro":
|
||||
@@ -2261,7 +2012,7 @@ func retargetDesktopOfficialRef(ref string, access map[string]bool) string {
|
||||
return ref
|
||||
}
|
||||
if !hasModel || strings.TrimSpace(model) == "" {
|
||||
model = "deepseek-v4-pro"
|
||||
model = deepSeekProModel
|
||||
}
|
||||
return "deepseek/" + model
|
||||
default:
|
||||
|
||||
@@ -996,7 +996,7 @@ func TestMigrateOfficialLegacyBaseURLUsesAnthropicEndpoint(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("load migrated config: %v", err)
|
||||
}
|
||||
for _, model := range []string{"deepseek-v4-flash", "deepseek-v4-pro"} {
|
||||
for _, model := range []string{DeepSeekFlashModel, deepSeekProModel} {
|
||||
p, ok := cfg.ResolveModel("deepseek/" + model)
|
||||
if !ok || p.Kind != "anthropic" || p.BaseURL != deepSeekAnthropicBaseURL || !EffectiveWebSearch(p) {
|
||||
t.Fatalf("%s official migration = %+v, want Anthropic endpoint with web search", model, p)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/billing"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
// Every rate this project has shipped as a default must still be recognised,
|
||||
// for every vendor: a config written under any past version is one this version
|
||||
// has to be able to bring up to date.
|
||||
func TestEverySupersededOfficialRateRefreshes(t *testing.T) {
|
||||
checked := 0
|
||||
for _, row := range billing.OfficialCatalog() {
|
||||
for _, card := range billing.SupersededRates(row.Provider, row.Model, row.Currency) {
|
||||
stored := pricingFromRateCard(&card)
|
||||
want := officialVendorPrice(row.Provider, row.Currency, row.Model)
|
||||
if got := supersededOfficialRefresh(row.Provider, row.Model, stored); !samePricing(got, want) {
|
||||
t.Errorf("%s/%s/%s: %+v refreshed to %+v, want the current %+v",
|
||||
row.Provider, row.Model, row.Currency, *stored, got, want)
|
||||
}
|
||||
checked++
|
||||
}
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Fatal("no superseded rate was exercised; the history has stopped keeping them")
|
||||
}
|
||||
}
|
||||
|
||||
// The refresh also has to survive the walk that reaches it. billing_currency is
|
||||
// unset here on purpose: it falls back to the vendor's default, and a rate
|
||||
// quoted in the other currency must not go unrecognised for that.
|
||||
func TestSupersededRateRefreshesThroughTheProviderWalk(t *testing.T) {
|
||||
stored := &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: "¥"}
|
||||
c := &Config{Providers: []ProviderEntry{{
|
||||
Name: "deepseek-flash", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
|
||||
Model: DeepSeekFlashModel, APIKeyEnv: "DEEPSEEK_API_KEY", Price: stored,
|
||||
}}}
|
||||
applyOfficialDefaultPricing(c)
|
||||
want := officialVendorPrice("deepseek", "CNY", DeepSeekFlashModel)
|
||||
if got := c.Providers[0].Price; !samePricing(got, want) {
|
||||
t.Fatalf("price = %+v, want the current official %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A rate the user actually chose is not ours to refresh.
|
||||
func TestCustomDeepSeekPriceSurvivesTheRefresh(t *testing.T) {
|
||||
custom := &provider.Pricing{CacheHit: 1, Input: 2, Output: 3, Currency: "$"}
|
||||
c := &Config{Providers: []ProviderEntry{{
|
||||
Name: "deepseek-flash", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL,
|
||||
Model: DeepSeekFlashModel, APIKeyEnv: "DEEPSEEK_API_KEY",
|
||||
Price: custom, BillingCurrency: "USD",
|
||||
}}}
|
||||
applyOfficialDefaultPricing(c)
|
||||
if !samePricing(c.Providers[0].Price, custom) {
|
||||
t.Fatalf("price = %+v, want the user's own %+v", c.Providers[0].Price, custom)
|
||||
}
|
||||
}
|
||||
|
||||
// config names DeepSeek's models; billing prices them. They are different
|
||||
// packages joined by plain strings, so a name in one that the other does not
|
||||
// price is a model whose cost reads as nothing — a turn that appears free.
|
||||
func TestEveryDeepSeekModelIsPriced(t *testing.T) {
|
||||
declared := append([]string{DeepSeekFlashModel, deepSeekProModel}, retiredDeepSeekFlashModels...)
|
||||
for _, model := range declared {
|
||||
for _, currency := range []string{"CNY", "USD"} {
|
||||
if billing.CurrentRate("deepseek", model, currency) == nil {
|
||||
t.Errorf("%s has no %s rate in billing's history", model, currency)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The catalog is what a quote actually reads, so a model the history prices
|
||||
// but the catalog never publishes still quotes as nothing.
|
||||
published := map[string]map[string]bool{}
|
||||
for _, row := range billing.OfficialCatalog() {
|
||||
if row.Provider != "deepseek" {
|
||||
continue
|
||||
}
|
||||
if published[row.Model] == nil {
|
||||
published[row.Model] = map[string]bool{}
|
||||
}
|
||||
published[row.Model][row.Currency] = true
|
||||
}
|
||||
for _, model := range declared {
|
||||
for _, currency := range []string{"CNY", "USD"} {
|
||||
if !published[model][currency] {
|
||||
t.Errorf("%s/%s is priced but never published to the catalog", model, currency)
|
||||
}
|
||||
}
|
||||
}
|
||||
for model := range published {
|
||||
if !slices.Contains(declared, model) {
|
||||
t.Errorf("the catalog prices %q, which config never names", model)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
// preset_upgrade.go — bringing an entry still shaped the way we shipped it up
|
||||
// to the preset that replaced that shape.
|
||||
package config
|
||||
|
||||
import "strings"
|
||||
|
||||
// shippedPreset is a shape this project once shipped. An entry matching one is
|
||||
// ours to bring forward; anything else its user curated, and curation is the
|
||||
// one thing an upgrade must never overwrite.
|
||||
type shippedPreset struct {
|
||||
PresetID string
|
||||
// Models is the list that shape shipped with. CurrentModels instead means
|
||||
// the shape shipped the list the preset still has — what moved was another
|
||||
// field, and the untouched list is how the entry is recognised all the same.
|
||||
Models []string
|
||||
CurrentModels bool
|
||||
// Window is the context window that shape shipped with. Nil identifies the
|
||||
// shape by its catalog alone; zero is itself a value one of them shipped, so
|
||||
// only a pointer tells "not checked" from "checked against nothing set".
|
||||
Window *int
|
||||
// Default, when set, has to match too: a shape that shipped one and had it
|
||||
// changed was changed by somebody.
|
||||
Default string
|
||||
// Vision is the list that shape shipped. Holding it, or nil, is unspoken;
|
||||
// anything else is a choice, an empty list most clearly of all — it says
|
||||
// nothing here reads images.
|
||||
Vision []string
|
||||
// ByName admits an entry written before presets carried an id, matched on
|
||||
// its name instead. Declared per preset, because admitting it where it was
|
||||
// never admitted would start migrating entries nobody has been migrating.
|
||||
ByName bool
|
||||
}
|
||||
|
||||
// shippedPresets is every shape a curated preset has had. Adding one is how a
|
||||
// preset change reaches the installs that already have the old shape — the
|
||||
// alternative is a migration function per vendor per change, which is what this
|
||||
// replaced, and each of those was one more place for the guard to be subtly
|
||||
// different.
|
||||
var shippedPresets = []shippedPreset{
|
||||
{PresetID: "kimi-cn", Models: legacyKimiAPIModels, Vision: legacyKimiAPIModels, ByName: true},
|
||||
{PresetID: "kimi-global", Models: legacyKimiAPIModels, Vision: legacyKimiAPIModels, ByName: true},
|
||||
{PresetID: "opencode-go", Models: legacyOpenCodeGoModels, ByName: true},
|
||||
{PresetID: "longcat-openai", Models: longCat20Models, Window: new(legacyLongCat20ContextWindow), Default: longCat20Models[0]},
|
||||
{PresetID: "longcat-anthropic", Models: longCat20Models, Window: new(legacyLongCat20ContextWindow), Default: longCat20Models[0]},
|
||||
{PresetID: "qwen-cn", CurrentModels: true, Window: new(0), ByName: true},
|
||||
{PresetID: "qwen-global", CurrentModels: true, Window: new(0), ByName: true},
|
||||
{PresetID: "qwen-coding-plan-cn", CurrentModels: true, Window: new(0), ByName: true},
|
||||
{PresetID: "qwen-coding-plan-cn-anthropic", CurrentModels: true, Window: new(0), ByName: true},
|
||||
{PresetID: "qwen-coding-plan-global", CurrentModels: true, Window: new(0), ByName: true},
|
||||
{PresetID: "qwen-coding-plan-global-anthropic", CurrentModels: true, Window: new(0), ByName: true},
|
||||
}
|
||||
|
||||
// upgradeShippedPresets brings every entry still carrying a shape we shipped up
|
||||
// to the preset it is today. It reports whether it changed anything, so a load
|
||||
// for edit persists that and a plain load only holds it in memory.
|
||||
func upgradeShippedPresets(c *Config) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
for i := range c.Providers {
|
||||
p := &c.Providers[i]
|
||||
for _, shape := range shippedPresets {
|
||||
canonical, ok := shape.matches(p)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
changed = shape.upgrade(p, canonical) || changed
|
||||
break
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// matches reports whether the entry is still the shape this preset shipped, and
|
||||
// returns the preset it would be brought up to.
|
||||
func (s shippedPreset) matches(p *ProviderEntry) (ProviderEntry, bool) {
|
||||
if p == nil || !s.identifies(p) {
|
||||
return ProviderEntry{}, false
|
||||
}
|
||||
preset, ok := CuratedProviderPreset(s.PresetID)
|
||||
if !ok || len(preset.Entries) != 1 {
|
||||
return ProviderEntry{}, false
|
||||
}
|
||||
canonical := preset.Entries[0]
|
||||
models := s.Models
|
||||
if s.CurrentModels {
|
||||
models = canonical.Models
|
||||
}
|
||||
// A single model in `model` rather than `models` is a shape nothing here
|
||||
// shipped, and replacing its catalog would answer a narrowing with a list.
|
||||
if !strings.EqualFold(strings.TrimSpace(p.Kind), strings.TrimSpace(canonical.Kind)) ||
|
||||
normalizedBaseURLForMigration(p.BaseURL) != normalizedBaseURLForMigration(canonical.BaseURL) ||
|
||||
!stringSlicesEqual(p.Models, models) ||
|
||||
strings.TrimSpace(p.Model) != "" {
|
||||
return ProviderEntry{}, false
|
||||
}
|
||||
if s.Window != nil && p.ContextWindow != *s.Window {
|
||||
return ProviderEntry{}, false
|
||||
}
|
||||
if s.Default != "" && p.Default != s.Default {
|
||||
return ProviderEntry{}, false
|
||||
}
|
||||
return canonical, true
|
||||
}
|
||||
|
||||
func (s shippedPreset) identifies(p *ProviderEntry) bool {
|
||||
presetID := strings.TrimSpace(p.PresetID)
|
||||
if presetID != "" {
|
||||
return presetID == s.PresetID
|
||||
}
|
||||
return s.ByName && strings.TrimSpace(p.Name) == s.PresetID
|
||||
}
|
||||
|
||||
// upgradeToPreset moves one entry onto the preset. The catalog is replaced —
|
||||
// the guard established the entry still lists what we put there — while the
|
||||
// per-model settings are only filled in where they are missing, never over a
|
||||
// value somebody chose.
|
||||
func (s shippedPreset) upgrade(p *ProviderEntry, canonical ProviderEntry) bool {
|
||||
added := addedModels(p.Models, canonical.Models)
|
||||
changed := false
|
||||
if !stringSlicesEqual(p.Models, canonical.Models) {
|
||||
p.Models = append([]string(nil), canonical.Models...)
|
||||
changed = true
|
||||
}
|
||||
// Only a shape that named its window moves one. Where the window was not
|
||||
// part of what identified the shape, the number on the entry is the user's:
|
||||
// they narrowed a context on purpose and an upgrade may not widen it back.
|
||||
if s.Window != nil && canonical.ContextWindow > 0 && p.ContextWindow != canonical.ContextWindow {
|
||||
p.ContextWindow = canonical.ContextWindow
|
||||
changed = true
|
||||
}
|
||||
if canonical.Default != "" && p.Default != canonical.Default && !p.HasModel(p.Default) {
|
||||
p.Default = canonical.Default
|
||||
changed = true
|
||||
}
|
||||
// Only the models this upgrade adds are marked image-taking, and only where
|
||||
// the preset says they are. vision_models narrows; widening it to the
|
||||
// preset's whole list would send images to models nobody ticked.
|
||||
if vision, ok := s.upgradedVisionModels(p, canonical, added); ok {
|
||||
p.VisionModels = vision
|
||||
changed = true
|
||||
}
|
||||
if mergeMissingModelOverrides(p, canonical.ModelOverrides) {
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// mergeMissingModelOverrides fills in what the preset states and the entry does
|
||||
// not — field by field, never over a value already set. An override is where a
|
||||
// user says what a model may do, and an upgrade that overwrote one would answer
|
||||
// a deliberate narrowing by widening it straight back.
|
||||
func mergeMissingModelOverrides(p *ProviderEntry, defaults map[string]ProviderModelOverride) bool {
|
||||
if p == nil || len(defaults) == 0 {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
for key, fallback := range defaults {
|
||||
// Matched case-insensitively against what is already there: a model id
|
||||
// spelled differently would otherwise get a second key, and then the
|
||||
// model has two answers and no rule for which one wins.
|
||||
target := key
|
||||
for existing := range p.ModelOverrides {
|
||||
if strings.EqualFold(strings.TrimSpace(existing), key) {
|
||||
target = existing
|
||||
break
|
||||
}
|
||||
}
|
||||
override, touched := p.ModelOverrides[target], false
|
||||
if strings.TrimSpace(override.ReasoningProtocol) == "" && strings.TrimSpace(fallback.ReasoningProtocol) != "" {
|
||||
override.ReasoningProtocol, touched = fallback.ReasoningProtocol, true
|
||||
}
|
||||
if override.SupportedEfforts == nil && len(fallback.SupportedEfforts) > 0 {
|
||||
override.SupportedEfforts, touched = append([]string(nil), fallback.SupportedEfforts...), true
|
||||
}
|
||||
if strings.TrimSpace(override.DefaultEffort) == "" && strings.TrimSpace(fallback.DefaultEffort) != "" &&
|
||||
containsString(normalizedEffortLevels(override.SupportedEfforts), fallback.DefaultEffort) {
|
||||
override.DefaultEffort, touched = fallback.DefaultEffort, true
|
||||
}
|
||||
if override.ContextWindow <= 0 && fallback.ContextWindow > 0 {
|
||||
override.ContextWindow, touched = fallback.ContextWindow, true
|
||||
}
|
||||
if override.MaxOutputTokens == 0 && fallback.MaxOutputTokens != 0 {
|
||||
override.MaxOutputTokens, touched = fallback.MaxOutputTokens, true
|
||||
}
|
||||
if override.Vision == nil && fallback.Vision != nil {
|
||||
override.Vision, touched = fallback.Vision, true
|
||||
}
|
||||
if !touched {
|
||||
continue
|
||||
}
|
||||
if p.ModelOverrides == nil {
|
||||
p.ModelOverrides = make(map[string]ProviderModelOverride, len(defaults))
|
||||
}
|
||||
p.ModelOverrides[target] = override
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// upgradedVisionModels marks the added models the preset calls image-taking,
|
||||
// ordering the result the way the preset states them so a second pass reads the
|
||||
// same as the first. Anything the user added that the preset does not know
|
||||
// keeps its place after them. An upgrade that adds nothing leaves the list
|
||||
// exactly as it was, reordering included.
|
||||
func (s shippedPreset) upgradedVisionModels(p *ProviderEntry, canonical ProviderEntry, added []string) ([]string, bool) {
|
||||
// nil is "never said" and is ours to fill; the list we shipped is ours to
|
||||
// move. Anything else they chose — an explicitly empty list most of all,
|
||||
// which says no model here reads images and must survive the upgrade.
|
||||
if p.VisionModels != nil && !(s.Vision != nil && stringSlicesEqual(p.VisionModels, s.Vision)) {
|
||||
return nil, false
|
||||
}
|
||||
marks := false
|
||||
for _, model := range added {
|
||||
if containsString(canonical.VisionModels, model) && !p.HasVisionModel(model) {
|
||||
marks = true
|
||||
}
|
||||
}
|
||||
if !marks {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]string, 0, len(p.VisionModels)+len(added))
|
||||
for _, model := range canonical.VisionModels {
|
||||
if p.HasVisionModel(model) || containsString(added, model) {
|
||||
out = append(out, model)
|
||||
}
|
||||
}
|
||||
for _, model := range p.VisionModels {
|
||||
if !containsString(out, model) {
|
||||
out = append(out, model)
|
||||
}
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// addedModels is what the preset lists and the entry did not.
|
||||
func addedModels(before, after []string) []string {
|
||||
var out []string
|
||||
for _, model := range after {
|
||||
if !containsString(before, model) {
|
||||
out = append(out, model)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
+127
-93
@@ -4,62 +4,58 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/billing"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
// DeepSeek bills two rates a day. These templates carry the off-peak one — the
|
||||
// base card in billing's catalog — and the peak hours are applied when a turn
|
||||
// is quoted, because only the clock knows which one a call was billed at.
|
||||
func deepSeekV4FlashPriceCNY() *provider.Pricing {
|
||||
return &provider.Pricing{CacheHit: 0.05, Input: 1.5, Output: 4.5, Currency: "¥"}
|
||||
// DeepSeek's rates live in billing's history, which publishes the current
|
||||
// generation and keeps the superseded ones. Reading them from there rather than
|
||||
// restating them means a price change is one append in one place.
|
||||
func deepSeekOfficialRate(model, currency string) *provider.Pricing {
|
||||
return officialVendorPrice("deepseek", currency, model)
|
||||
}
|
||||
|
||||
func deepSeekV4ProPriceCNY() *provider.Pricing {
|
||||
return &provider.Pricing{CacheHit: 0.15, Input: 4.5, Output: 13.5, Currency: "¥"}
|
||||
}
|
||||
|
||||
func deepSeekV4PricesCNY() map[string]*provider.Pricing {
|
||||
return map[string]*provider.Pricing{
|
||||
"deepseek-v4-flash": deepSeekV4FlashPriceCNY(),
|
||||
"deepseek-v4-pro": deepSeekV4ProPriceCNY(),
|
||||
// Vision bills at the flash rates; an image is charged as the tokens
|
||||
// it scales to, so it lands in the prompt total already counted.
|
||||
DeepSeekVisionModel: deepSeekV4FlashPriceCNY(),
|
||||
func pricingFromRateCard(card *billing.RateCard) *provider.Pricing {
|
||||
if card == nil {
|
||||
return nil
|
||||
}
|
||||
return &provider.Pricing{
|
||||
CacheHit: card.CacheHit, Input: card.Input, Output: card.Output,
|
||||
Currency: billing.CurrencySymbol(card.Currency),
|
||||
}
|
||||
}
|
||||
|
||||
func deepSeekV4FlashPriceUSD() *provider.Pricing {
|
||||
return &provider.Pricing{CacheHit: 0.007, Input: 0.22, Output: 0.66, Currency: "$"}
|
||||
func deepSeekOfficialPricesCNY() map[string]*provider.Pricing {
|
||||
return deepSeekOfficialRates("CNY")
|
||||
}
|
||||
|
||||
func deepSeekV4ProPriceUSD() *provider.Pricing {
|
||||
return &provider.Pricing{CacheHit: 0.022, Input: 0.66, Output: 1.98, Currency: "$"}
|
||||
func deepSeekOfficialPricesUSD() map[string]*provider.Pricing {
|
||||
return deepSeekOfficialRates("USD")
|
||||
}
|
||||
|
||||
func deepSeekV4PricesUSD() map[string]*provider.Pricing {
|
||||
return map[string]*provider.Pricing{
|
||||
"deepseek-v4-flash": deepSeekV4FlashPriceUSD(),
|
||||
"deepseek-v4-pro": deepSeekV4ProPriceUSD(),
|
||||
DeepSeekVisionModel: deepSeekV4FlashPriceUSD(),
|
||||
}
|
||||
// deepSeekOfficialRates is what a connection to the official endpoint is offered.
|
||||
// The retired names are priced too but not listed: a new entry seeded with one
|
||||
// would be offering a model nobody should pick.
|
||||
func deepSeekOfficialRates(currency string) map[string]*provider.Pricing {
|
||||
return officialVendorPrices("deepseek", currency, []string{DeepSeekFlashModel, deepSeekProModel})
|
||||
}
|
||||
|
||||
// DeepSeekV4PricesForCurrency returns the official regional price table.
|
||||
// DeepSeekOfficialPricesForCurrency returns the official regional price table.
|
||||
// Persisted custom prices still win; this is only used for built-in templates
|
||||
// and known-default refreshes.
|
||||
func DeepSeekV4PricesForCurrency(currency string) map[string]*provider.Pricing {
|
||||
func DeepSeekOfficialPricesForCurrency(currency string) map[string]*provider.Pricing {
|
||||
if normalizeDeepSeekPricingCurrency(currency) == "CNY" {
|
||||
return deepSeekV4PricesCNY()
|
||||
return deepSeekOfficialPricesCNY()
|
||||
}
|
||||
return deepSeekV4PricesUSD()
|
||||
return deepSeekOfficialPricesUSD()
|
||||
}
|
||||
|
||||
func deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {
|
||||
return DeepSeekV4PricesForCurrency(c.DeepSeekOfficialPricingCurrency())
|
||||
func deepSeekOfficialPricesForConfig(c *Config) map[string]*provider.Pricing {
|
||||
return DeepSeekOfficialPricesForCurrency(c.DeepSeekOfficialPricingCurrency())
|
||||
}
|
||||
|
||||
func deepSeekV4PriceForModel(currency, model string) *provider.Pricing {
|
||||
return clonePricing(DeepSeekV4PricesForCurrency(currency)[strings.TrimSpace(model)])
|
||||
func deepSeekOfficialPriceForModel(currency, model string) *provider.Pricing {
|
||||
return clonePricing(DeepSeekOfficialPricesForCurrency(currency)[billing.RateModelFor("deepseek", model)])
|
||||
}
|
||||
|
||||
// DeepSeekOfficialPricingLanguage is retained for older settings/template call
|
||||
@@ -83,42 +79,49 @@ func normalizeDeepSeekPricingCurrency(currency string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek
|
||||
// ApplyOfficialDefaultPricing refreshes built-in/official vendor
|
||||
// prices that still match known official defaults for each provider's frozen
|
||||
// billing_currency. Custom user prices and display-currency switches never
|
||||
// rewrite list prices.
|
||||
func (c *Config) ApplyDeepSeekOfficialDefaultPricing() {
|
||||
applyDeepSeekOfficialDefaultPricing(c)
|
||||
func (c *Config) ApplyOfficialDefaultPricing() {
|
||||
applyOfficialDefaultPricing(c)
|
||||
}
|
||||
|
||||
func applyDeepSeekOfficialDefaultPricing(c *Config) {
|
||||
applyDeepSeekOfficialDefaultPricingWithOverride(c, false)
|
||||
func applyOfficialDefaultPricing(c *Config) {
|
||||
applyOfficialDefaultPricingWithOverride(c, false)
|
||||
}
|
||||
|
||||
func applyDeepSeekOfficialDefaultPricingWithOverride(c *Config, overridePersisted bool) {
|
||||
func applyOfficialDefaultPricingWithOverride(c *Config, overridePersisted bool) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
for i := range c.Providers {
|
||||
p := &c.Providers[i]
|
||||
if officialProviderKind(p) != "deepseek" {
|
||||
vendor := officialProviderKind(p)
|
||||
if vendor == "" {
|
||||
continue
|
||||
}
|
||||
currency := p.ProviderBillingCurrency()
|
||||
if currency == "" {
|
||||
currency = "USD"
|
||||
currency = billing.DefaultCurrency(vendor)
|
||||
}
|
||||
// Only refresh when the row still matches a known official default in
|
||||
// the provider's own billing currency. Display currency must not win.
|
||||
if isKnownDeepSeekOfficialPricing(p.Model, p.Price) && (overridePersisted || p.persistedOfficialCurrency == "" || p.persistedOfficialCurrency == currency) {
|
||||
if samePricing(p.Price, deepSeekV4PriceForModel(currency, p.Model)) || overridePersisted {
|
||||
p.Price = deepSeekV4PriceForModel(currency, p.Model)
|
||||
if isKnownOfficialPricing(vendor, p.Model, p.Price) && (overridePersisted || p.persistedOfficialCurrency == "" || p.persistedOfficialCurrency == currency) {
|
||||
switch refreshed := supersededOfficialRefresh(vendor, p.Model, p.Price); {
|
||||
case samePricing(p.Price, officialVendorPrice(vendor, currency, p.Model)) || overridePersisted:
|
||||
p.Price = officialVendorPrice(vendor, currency, p.Model)
|
||||
case refreshed != nil:
|
||||
p.Price = refreshed
|
||||
}
|
||||
}
|
||||
for model, price := range p.Prices {
|
||||
if isKnownDeepSeekOfficialPricing(model, price) && (overridePersisted || p.persistedOfficialCurrency == "" || p.persistedOfficialCurrency == currency) {
|
||||
if samePricing(price, deepSeekV4PriceForModel(currency, model)) || overridePersisted {
|
||||
p.Prices[model] = deepSeekV4PriceForModel(currency, model)
|
||||
if isKnownOfficialPricing(vendor, model, price) && (overridePersisted || p.persistedOfficialCurrency == "" || p.persistedOfficialCurrency == currency) {
|
||||
switch refreshed := supersededOfficialRefresh(vendor, model, price); {
|
||||
case samePricing(price, officialVendorPrice(vendor, currency, model)) || overridePersisted:
|
||||
p.Prices[model] = officialVendorPrice(vendor, currency, model)
|
||||
case refreshed != nil:
|
||||
p.Prices[model] = refreshed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,7 +164,7 @@ func completeDeepSeekOfficialPricingCurrency(p *ProviderEntry) string {
|
||||
return ""
|
||||
}
|
||||
models := p.ModelList()
|
||||
if len(models) == 1 && isKnownDeepSeekOfficialPricing(models[0], p.Price) {
|
||||
if len(models) == 1 && isKnownOfficialPricing("deepseek", models[0], p.Price) {
|
||||
return normalizeDeepSeekPricingCurrency(p.Price.Currency)
|
||||
}
|
||||
if len(models) == 0 || p.Price != nil {
|
||||
@@ -170,7 +173,7 @@ func completeDeepSeekOfficialPricingCurrency(p *ProviderEntry) string {
|
||||
currency := ""
|
||||
for _, model := range models {
|
||||
price := p.Prices[strings.TrimSpace(model)]
|
||||
if !isKnownDeepSeekOfficialPricing(model, price) {
|
||||
if !isKnownOfficialPricing("deepseek", model, price) {
|
||||
return ""
|
||||
}
|
||||
nextCurrency := normalizeDeepSeekPricingCurrency(price.Currency)
|
||||
@@ -186,46 +189,30 @@ func completeDeepSeekOfficialPricingCurrency(p *ProviderEntry) string {
|
||||
return currency
|
||||
}
|
||||
|
||||
func mimoV25ProPrice() *provider.Pricing {
|
||||
return &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: "¥"}
|
||||
// officialVendorPrices is a vendor's current rate for each model it prices.
|
||||
// One reader for every vendor: the rates live in billing's history, and a table
|
||||
// restated here is one that drifts from it the first time either side moves.
|
||||
func officialVendorPrices(vendor, currency string, models []string) map[string]*provider.Pricing {
|
||||
prices := make(map[string]*provider.Pricing, len(models))
|
||||
for _, model := range models {
|
||||
model = strings.TrimSpace(model)
|
||||
if rate := officialVendorPrice(vendor, currency, model); rate != nil {
|
||||
prices[model] = rate
|
||||
}
|
||||
}
|
||||
return prices
|
||||
}
|
||||
|
||||
func mimoV25Price() *provider.Pricing {
|
||||
return &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: "¥"}
|
||||
}
|
||||
|
||||
func mimoV2FlashPrice() *provider.Pricing {
|
||||
return &provider.Pricing{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: "¥"}
|
||||
func officialVendorPrice(vendor, currency, model string) *provider.Pricing {
|
||||
return pricingFromRateCard(billing.CurrentRate(vendor, model, currency))
|
||||
}
|
||||
|
||||
func mimoDomesticPrices(models []string) map[string]*provider.Pricing {
|
||||
prices := map[string]*provider.Pricing{}
|
||||
for _, model := range models {
|
||||
switch strings.TrimSpace(model) {
|
||||
case "mimo-v2.5-pro", "mimo-v2-pro":
|
||||
prices[model] = mimoV25ProPrice()
|
||||
case "mimo-v2.5", "mimo-v2-omni":
|
||||
prices[model] = mimoV25Price()
|
||||
case "mimo-v2-flash":
|
||||
prices[model] = mimoV2FlashPrice()
|
||||
}
|
||||
}
|
||||
return prices
|
||||
}
|
||||
|
||||
func longCat20Price() *provider.Pricing {
|
||||
return &provider.Pricing{CacheHit: 0.04, Input: 2, Output: 8, Currency: "¥"}
|
||||
return officialVendorPrices("mimo", "CNY", models)
|
||||
}
|
||||
|
||||
func longCat20Prices(models []string) map[string]*provider.Pricing {
|
||||
prices := map[string]*provider.Pricing{}
|
||||
for _, model := range models {
|
||||
switch strings.TrimSpace(model) {
|
||||
case "LongCat-2.0":
|
||||
prices[model] = longCat20Price()
|
||||
}
|
||||
}
|
||||
return prices
|
||||
return officialVendorPrices("longcat", "CNY", models)
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -324,7 +311,7 @@ func resetOfficialProviderPricingDefaults(c *Config) {
|
||||
p := &c.Providers[i]
|
||||
switch {
|
||||
case officialProviderKind(p) == "deepseek":
|
||||
resetDeepSeekOfficialPricing(p, deepSeekV4PricesForConfig(c))
|
||||
resetDeepSeekOfficialPricing(p, deepSeekOfficialPricesForConfig(c))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -351,23 +338,38 @@ func resetDeepSeekOfficialPricing(p *ProviderEntry, defaults map[string]*provide
|
||||
}
|
||||
}
|
||||
|
||||
func isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {
|
||||
func isKnownOfficialPricing(vendor, model string, price *provider.Pricing) bool {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" || price == nil {
|
||||
return false
|
||||
}
|
||||
for _, prices := range []map[string]*provider.Pricing{deepSeekV4PricesCNY(), deepSeekV4PricesUSD()} {
|
||||
if samePricing(price, prices[model]) {
|
||||
return true
|
||||
}
|
||||
if samePricing(price, officialVendorPrice(vendor, price.Currency, model)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return supersededOfficialRefresh(vendor, model, price) != nil
|
||||
}
|
||||
|
||||
// IsKnownDeepSeekOfficialPricing reports whether price is one of Reasonix's
|
||||
// built-in DeepSeek regional defaults for model.
|
||||
func IsKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {
|
||||
return isKnownDeepSeekOfficialPricing(model, price)
|
||||
// supersededOfficialRefresh is the current rate for a stored price this project
|
||||
// shipped before, in that price's own currency. The currency comes from the
|
||||
// price rather than from the entry: an entry that never declared one falls back
|
||||
// to the vendor's default, and a rate quoted in the other one would go
|
||||
// unrecognised and never be updated.
|
||||
func supersededOfficialRefresh(vendor, model string, price *provider.Pricing) *provider.Pricing {
|
||||
if price == nil {
|
||||
return nil
|
||||
}
|
||||
for _, card := range billing.SupersededRates(vendor, model, price.Currency) {
|
||||
if samePricing(price, pricingFromRateCard(&card)) {
|
||||
return officialVendorPrice(vendor, price.Currency, model)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsKnownOfficialPricing reports whether price is one of Reasonix's built-in
|
||||
// defaults for a vendor's model — the current rate or one it has superseded.
|
||||
func IsKnownOfficialPricing(vendor, model string, price *provider.Pricing) bool {
|
||||
return isKnownOfficialPricing(vendor, model, price)
|
||||
}
|
||||
|
||||
func samePricing(a, b *provider.Pricing) bool {
|
||||
@@ -376,3 +378,35 @@ func samePricing(a, b *provider.Pricing) bool {
|
||||
}
|
||||
return a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.Currency == b.Currency
|
||||
}
|
||||
|
||||
func backfillDeepSeekOfficialPrices(c *Config) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
for i := range c.Providers {
|
||||
p := &c.Providers[i]
|
||||
if officialProviderKind(p) != "deepseek" {
|
||||
continue
|
||||
}
|
||||
backfillDeepSeekOfficialEndpointDefaults(p)
|
||||
currency := p.ProviderBillingCurrency()
|
||||
if currency == "" {
|
||||
currency = p.persistedOfficialCurrency
|
||||
}
|
||||
if currency == "" {
|
||||
currency = "USD"
|
||||
}
|
||||
if p.Price != nil {
|
||||
continue
|
||||
}
|
||||
if p.Prices == nil {
|
||||
p.Prices = map[string]*provider.Pricing{}
|
||||
}
|
||||
// From the entry's list, not the table's: a retired name is priced too.
|
||||
for _, model := range p.ModelList() {
|
||||
if price := deepSeekOfficialPriceForModel(currency, model); price != nil && p.Prices[model] == nil {
|
||||
p.Prices[model] = price
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,10 +196,18 @@ func describe(baseURL string, s shape, chat []string) Probe {
|
||||
return p
|
||||
}
|
||||
|
||||
// inferVisionModelsForEndpoint answers from the curated preset covering this
|
||||
// address wherever there is one. Which models read images is a fact about the
|
||||
// vendor and not about how it spells a name: the spelling misses every Kimi,
|
||||
// Qwen, MiniMax and Claude model these presets declare. Only an undeclared
|
||||
// address falls back to it.
|
||||
func inferVisionModelsForEndpoint(baseURL, kind string, models []string) []string {
|
||||
inferred := InferVisionModels(models)
|
||||
out := make([]string, 0, len(inferred))
|
||||
for _, model := range inferred {
|
||||
candidates, declared := declaredVisionModels(baseURL, models)
|
||||
if !declared {
|
||||
candidates = InferVisionModels(models)
|
||||
}
|
||||
out := make([]string, 0, len(candidates))
|
||||
for _, model := range candidates {
|
||||
entry := &ProviderEntry{Kind: kind, BaseURL: baseURL, Model: model}
|
||||
if CanConfigureVision(entry) {
|
||||
out = append(out, model)
|
||||
@@ -217,3 +225,33 @@ func chatModelsOf(models []string) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// declaredVisionModels is what the presets for this address say reads images,
|
||||
// narrowed to the models on offer. Matched on the address, not the protocol: a
|
||||
// vendor serving one catalog over two endpoint shapes has not changed which of
|
||||
// its models can see. The second return tells "these presets state none" from
|
||||
// "no preset covers this address".
|
||||
func declaredVisionModels(baseURL string, models []string) ([]string, bool) {
|
||||
declared, found := map[string]bool{}, false
|
||||
for _, preset := range CuratedProviderPresets() {
|
||||
for _, entry := range preset.Entries {
|
||||
if normalizedBaseURLForMigration(entry.BaseURL) != normalizedBaseURLForMigration(baseURL) {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
for _, model := range entry.VisionModels {
|
||||
declared[model] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]string, 0, len(declared))
|
||||
for _, model := range models {
|
||||
if declared[model] {
|
||||
out = append(out, model)
|
||||
}
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
@@ -168,10 +168,12 @@ func TestProbeReadsCapabilitiesFromTheSameRegistries(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProbeUsesDeepSeekOfficialVisionCatalog(t *testing.T) {
|
||||
models := []string{"deepseek-v4-flash", DeepSeekVisionModel, "deepseek-v5-vision"}
|
||||
models := []string{DeepSeekFlashModel, deepSeekProModel, "deepseek-v5-vision"}
|
||||
got := describe("https://api.deepseek.com", shape{kind: "openai"}, models)
|
||||
if !slices.Equal(got.Vision, []string{DeepSeekVisionModel}) {
|
||||
t.Fatalf("vision = %v, want only the vendor-declared model", got.Vision)
|
||||
// Flash spells nothing about vision and pro does not refuse an image, so a
|
||||
// name reading would take the wrong two of these three.
|
||||
if !slices.Equal(got.Vision, []string{DeepSeekFlashModel}) {
|
||||
t.Fatalf("vision = %v, want only the model the catalog declares", got.Vision)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,3 +250,47 @@ func TestLegacyResponsesStatefulFoldsIntoMode(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A curated preset states which of its models read images. Probing that same
|
||||
// address has to answer with what the preset says rather than with what the
|
||||
// names look like: the spelling misses every Kimi, Qwen, MiniMax and Claude
|
||||
// model these presets declare, so a user who typed the address instead of
|
||||
// picking the preset from the list would be told none of them can see.
|
||||
func TestProbeAnswersFromThePresetCoveringTheAddress(t *testing.T) {
|
||||
checked := 0
|
||||
for _, preset := range CuratedProviderPresets() {
|
||||
for _, entry := range preset.Entries {
|
||||
if len(entry.Models) == 0 || len(entry.VisionModels) == 0 {
|
||||
continue
|
||||
}
|
||||
got := describe(entry.BaseURL, shape{kind: entry.Kind}, entry.Models)
|
||||
for _, model := range entry.VisionModels {
|
||||
if !containsString(entry.Models, model) {
|
||||
continue
|
||||
}
|
||||
if !containsString(got.Vision, model) {
|
||||
t.Errorf("%s: probing %s offered %v, and the preset declares %s reads images",
|
||||
preset.ID, entry.BaseURL, got.Vision, model)
|
||||
}
|
||||
checked++
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Fatal("no preset declared an image-taking model; this proved nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// An address no preset covers is one nothing has declared, and the spelling is
|
||||
// all that is left. It stays a suggestion — conservative, and correctable in
|
||||
// Settings — rather than a claim about a vendor nobody has checked.
|
||||
func TestProbeFallsBackToTheSpellingForUndeclaredAddresses(t *testing.T) {
|
||||
models := []string{"some-vl-model", "plain-text-model"}
|
||||
got := describe("https://relay.example.com/v1", shape{kind: "openai"}, models)
|
||||
if !containsString(got.Vision, "some-vl-model") || containsString(got.Vision, "plain-text-model") {
|
||||
t.Fatalf("vision = %v, want the spelling's suggestion for an undeclared address", got.Vision)
|
||||
}
|
||||
if _, declared := declaredVisionModels("https://relay.example.com/v1", models); declared {
|
||||
t.Fatal("an address no preset covers reported as declared")
|
||||
}
|
||||
}
|
||||
@@ -191,7 +191,7 @@ func assertOfficialDeepSeekDefaults(t *testing.T, tag string, p *ProviderEntry)
|
||||
if p.BalanceURL != "https://api.deepseek.com/user/balance" {
|
||||
t.Errorf("%s: official DeepSeek provider has balance_url %q, want the vendor wallet endpoint; empty removes the balance readout", tag, p.BalanceURL)
|
||||
}
|
||||
if p.Prices["deepseek-v4-flash"] == nil {
|
||||
if p.Prices[DeepSeekFlashModel] == nil {
|
||||
t.Errorf("%s: official DeepSeek provider lost its per-model price backfill: prices=%v", tag, p.Prices)
|
||||
}
|
||||
}
|
||||
@@ -311,7 +311,7 @@ context_window = 65536
|
||||
// The fold carries a declared per-model value into the canonical entry's
|
||||
// overrides, so the question is what resolution hands the agent — the
|
||||
// provider-wide field is no longer where that answer lives.
|
||||
p, ok := cfg.ResolveModel("deepseek-flash/deepseek-v4-flash")
|
||||
p, ok := cfg.ResolveModel("deepseek-flash/" + DeepSeekFlashModel)
|
||||
if !ok {
|
||||
t.Fatal("official DeepSeek provider missing after load")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// provider_model_rename.go — pointing one entry's model id at another.
|
||||
package config
|
||||
|
||||
import "slices"
|
||||
|
||||
// renameProviderModel replaces a model id throughout one provider entry: the
|
||||
// fields that name it and the maps keyed by it. A price or an effort list left
|
||||
// under the old key would stop answering for the model that replaced it, which
|
||||
// reads downstream as an edited setting reverting to its default.
|
||||
func renameProviderModel(p *ProviderEntry, from, to string) bool {
|
||||
if p == nil || from == "" || to == "" || from == to {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
if p.Model == from {
|
||||
p.Model, changed = to, true
|
||||
}
|
||||
if p.Default == from {
|
||||
p.Default, changed = to, true
|
||||
}
|
||||
for _, list := range []*[]string{&p.Models, &p.VisionModels} {
|
||||
if renamed, ok := renameInModelList(*list, from, to); ok {
|
||||
*list, changed = renamed, true
|
||||
}
|
||||
}
|
||||
if renameMapKey(p.Prices, from, to) {
|
||||
changed = true
|
||||
}
|
||||
if renameMapKey(p.ModelOverrides, from, to) {
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// renameInModelList dedupes as it renames: an entry naming both ids would
|
||||
// otherwise come out listing the surviving model twice.
|
||||
func renameInModelList(list []string, from, to string) ([]string, bool) {
|
||||
if !slices.Contains(list, from) {
|
||||
return list, false
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for _, model := range list {
|
||||
if model == from {
|
||||
model = to
|
||||
}
|
||||
if !slices.Contains(out, model) {
|
||||
out = append(out, model)
|
||||
}
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// renameMapKey moves one key's value. A value already under the new key wins:
|
||||
// it was chosen for the model that survives, the other for the one that did not.
|
||||
func renameMapKey[V any](m map[string]V, from, to string) bool {
|
||||
v, ok := m[from]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if _, taken := m[to]; !taken {
|
||||
m[to] = v
|
||||
}
|
||||
delete(m, from)
|
||||
return true
|
||||
}
|
||||
@@ -83,7 +83,6 @@ var (
|
||||
kimiCodingModels = []string{"kimi-for-coding"}
|
||||
|
||||
longCat20Models = []string{"LongCat-2.0"}
|
||||
deepSeekV4Models = []string{"deepseek-v4-flash", "deepseek-v4-pro"}
|
||||
tokenRhythmModels = []string{
|
||||
"deepseek-v4-flash", "deepseek-v4-pro", "glm-5", "glm-5.1",
|
||||
"minimax-m2.7", "kimi-k2.5", "kimi-k2.6", "minimax-m2.5",
|
||||
@@ -97,7 +96,6 @@ var (
|
||||
|
||||
minimaxMSeriesModels = []string{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed"}
|
||||
minimaxMSeriesVisionModels = []string{"MiniMax-M3"}
|
||||
deepSeekResponsesModels = []string{"deepseek-v4-flash"}
|
||||
|
||||
glmAPIModels = []string{"glm-5.2", "glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7", "glm-4.7-flash", "glm-4.7-flashx", "glm-4.6", "glm-4.5", "glm-4.5-air", "glm-4.5-flash"}
|
||||
glmAPIVisionModels = []string{"glm-5v-turbo"}
|
||||
@@ -188,29 +186,8 @@ func kimiK3DirectOverride() ProviderModelOverride {
|
||||
}
|
||||
|
||||
var curatedProviderPresets = []ProviderPreset{
|
||||
{
|
||||
ID: "deepseek-anthropic",
|
||||
Label: "DeepSeek Official Anthropic",
|
||||
Description: "Separate official DeepSeek Anthropic-compatible entry for Flash and Pro.",
|
||||
KeyEnv: "DEEPSEEK_API_KEY",
|
||||
Entries: []ProviderEntry{{
|
||||
Name: "deepseek-anthropic",
|
||||
Kind: "anthropic",
|
||||
BaseURL: deepSeekAnthropicBaseURL,
|
||||
Models: deepSeekV4Models,
|
||||
Default: "deepseek-v4-flash",
|
||||
APIKeyEnv: "DEEPSEEK_API_KEY",
|
||||
BalanceURL: "https://api.deepseek.com/user/balance",
|
||||
Thinking: "enabled",
|
||||
WebSearch: new(true),
|
||||
ContextWindow: 1_000_000,
|
||||
Prices: deepSeekV4PricesUSD(),
|
||||
ModelOverrides: map[string]ProviderModelOverride{
|
||||
"deepseek-v4-flash": {SupportedEfforts: []string{"disabled", "low", "high", "max"}, DefaultEffort: "high"},
|
||||
"deepseek-v4-pro": {SupportedEfforts: []string{"disabled", "high", "max"}, DefaultEffort: "high"},
|
||||
},
|
||||
}},
|
||||
},
|
||||
deepSeekAnthropicPreset(),
|
||||
deepSeekResponsesPreset(),
|
||||
{
|
||||
ID: "longcat-openai",
|
||||
Label: "LongCat OpenAI",
|
||||
@@ -542,27 +519,6 @@ var curatedProviderPresets = []ProviderPreset{
|
||||
ContextWindow: 1048576,
|
||||
}},
|
||||
},
|
||||
{
|
||||
ID: "deepseek-responses",
|
||||
Label: "DeepSeek Official Responses API",
|
||||
Description: "Official stateless DeepSeek Responses API for Flash with server-side web search.",
|
||||
KeyEnv: "DEEPSEEK_API_KEY",
|
||||
Entries: []ProviderEntry{{
|
||||
Name: "deepseek-responses",
|
||||
Kind: "responses",
|
||||
BaseURL: "https://api.deepseek.com",
|
||||
Models: deepSeekResponsesModels,
|
||||
Default: "deepseek-v4-flash",
|
||||
APIKeyEnv: "DEEPSEEK_API_KEY",
|
||||
BalanceURL: "https://api.deepseek.com/user/balance",
|
||||
ContextWindow: 1_000_000,
|
||||
Price: deepSeekV4FlashPriceUSD(),
|
||||
ResponsesMode: "stateless",
|
||||
WebSearch: new(true),
|
||||
SupportedEfforts: []string{"low", "high", "max"},
|
||||
DefaultEffort: "high",
|
||||
}},
|
||||
},
|
||||
{
|
||||
ID: "glm-cn",
|
||||
Label: "GLM CN API",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -144,18 +145,18 @@ func TestDeepSeekAnthropicPresetIsOptionalAndModelScoped(t *testing.T) {
|
||||
t.Fatalf("DeepSeek Anthropic preset = %+v, want one entry", preset)
|
||||
}
|
||||
entry := preset.Entries[0]
|
||||
if entry.Kind != "anthropic" || entry.BaseURL != deepSeekAnthropicBaseURL || entry.Default != "deepseek-v4-flash" || entry.Thinking != "enabled" || !EffectiveWebSearch(&entry) || entry.Vision || entry.APIKeyEnv != "DEEPSEEK_API_KEY" {
|
||||
if entry.Kind != "anthropic" || entry.BaseURL != deepSeekAnthropicBaseURL || entry.Default != DeepSeekFlashModel || entry.Thinking != "enabled" || !EffectiveWebSearch(&entry) || entry.Vision || entry.APIKeyEnv != "DEEPSEEK_API_KEY" {
|
||||
t.Fatalf("DeepSeek Anthropic preset entry = %+v", entry)
|
||||
}
|
||||
var cfg Config
|
||||
if err := cfg.UpsertProvider(entry); err != nil {
|
||||
t.Fatalf("UpsertProvider: %v", err)
|
||||
}
|
||||
flash, ok := cfg.ResolveModel("deepseek-anthropic/deepseek-v4-flash")
|
||||
flash, ok := cfg.ResolveModel("deepseek-anthropic/" + DeepSeekFlashModel)
|
||||
if !ok {
|
||||
t.Fatal("Flash model did not resolve")
|
||||
}
|
||||
pro, ok := cfg.ResolveModel("deepseek-anthropic/deepseek-v4-pro")
|
||||
pro, ok := cfg.ResolveModel("deepseek-anthropic/" + deepSeekProModel)
|
||||
if !ok {
|
||||
t.Fatal("Pro model did not resolve")
|
||||
}
|
||||
@@ -179,13 +180,13 @@ func TestDeepSeekResponsesPresetMatchesOfficialSupport(t *testing.T) {
|
||||
if entry.Kind != "responses" || entry.BaseURL != "https://api.deepseek.com" || entry.ResponsesMode != "stateless" {
|
||||
t.Fatalf("deepseek responses endpoint = %+v", entry)
|
||||
}
|
||||
if len(entry.Models) != 1 || entry.Models[0] != "deepseek-v4-flash" || entry.Default != "deepseek-v4-flash" {
|
||||
if len(entry.Models) != 1 || entry.Models[0] != DeepSeekFlashModel || entry.Default != DeepSeekFlashModel {
|
||||
t.Fatalf("deepseek responses models = %v default=%q", entry.Models, entry.Default)
|
||||
}
|
||||
if entry.ModelsURL != "" {
|
||||
t.Fatalf("deepseek responses models URL = %q, want static supported-model list", entry.ModelsURL)
|
||||
}
|
||||
if !EffectiveWebSearch(&entry) || entry.Vision || entry.VisionModels != nil {
|
||||
if !EffectiveWebSearch(&entry) || entry.Vision || !slices.Equal(entry.VisionModels, []string{DeepSeekFlashModel}) {
|
||||
t.Fatalf("deepseek responses capabilities = web_search:%t vision:%t vision_models:%v", EffectiveWebSearch(&entry), entry.Vision, entry.VisionModels)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ func renderDesktopSection(b *strings.Builder, c *Config) {
|
||||
if currency := c.DesktopCurrency(); currency != "" {
|
||||
fmt.Fprintf(b, "currency = %q # legacy display currency; prefer [billing].display_currency\n", currency)
|
||||
}
|
||||
fmt.Fprintf(b, "layout_style = %q # desktop layout: classic|workbench|creation\n", c.DesktopLayoutStyle())
|
||||
fmt.Fprintf(b, "theme = %q # desktop only: auto|dark|light\n", c.DesktopTheme())
|
||||
fmt.Fprintf(b, "terminal_theme = %q # integrated terminal: auto|dark|light; auto follows the desktop app\n", c.DesktopTerminalTheme())
|
||||
if style := c.DesktopThemeStyle(); style != "" {
|
||||
|
||||
@@ -230,7 +230,6 @@ func TestRenderTOMLRoundTrips(t *testing.T) {
|
||||
orig.UI.ShortcutLayout = "desktop"
|
||||
orig.UI.CursorShape = "bar"
|
||||
orig.Desktop.Language = "en"
|
||||
orig.Desktop.LayoutStyle = "workbench"
|
||||
orig.Desktop.Theme = "dark"
|
||||
orig.Desktop.ThemeStyle = "graphite"
|
||||
orig.Desktop.TerminalTheme = "light"
|
||||
@@ -382,9 +381,6 @@ func TestRenderTOMLRoundTrips(t *testing.T) {
|
||||
if got.Desktop.Language != "en" {
|
||||
t.Errorf("desktop.language = %q, want en", got.Desktop.Language)
|
||||
}
|
||||
if got.Desktop.LayoutStyle != "workbench" {
|
||||
t.Errorf("desktop.layout_style = %q, want workbench", got.Desktop.LayoutStyle)
|
||||
}
|
||||
if got.Desktop.Theme != "dark" {
|
||||
t.Errorf("desktop.theme = %q, want dark", got.Desktop.Theme)
|
||||
}
|
||||
@@ -675,24 +671,6 @@ func TestRenderTOMLPreservesMCPTimeouts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTOMLCreationLayoutStyle(t *testing.T) {
|
||||
c := Default()
|
||||
if err := c.SetDesktopLayoutStyle("creation"); err != nil {
|
||||
t.Fatalf("SetDesktopLayoutStyle: %v", err)
|
||||
}
|
||||
rendered := RenderTOML(c)
|
||||
var got Config
|
||||
if _, err := toml.Decode(rendered, &got); err != nil {
|
||||
t.Fatalf("rendered TOML does not parse: %v\n---\n%s", err, rendered)
|
||||
}
|
||||
if got.Desktop.LayoutStyle != "creation" {
|
||||
t.Errorf("desktop.layout_style = %q, want creation", got.Desktop.LayoutStyle)
|
||||
}
|
||||
if got.DesktopLayoutStyle() != "creation" {
|
||||
t.Errorf("DesktopLayoutStyle() = %q, want creation", got.DesktopLayoutStyle())
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedRenderPreservesLSPConfig(t *testing.T) {
|
||||
const src = `
|
||||
config_version = 4
|
||||
@@ -1033,7 +1011,7 @@ func TestRenderTOMLRoundTripsPerModelPrices(t *testing.T) {
|
||||
Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
|
||||
Default: "deepseek-v4-flash",
|
||||
APIKeyEnv: "DEEPSEEK_API_KEY",
|
||||
Prices: DeepSeekV4PricesForCurrency("CNY"),
|
||||
Prices: DeepSeekOfficialPricesForCurrency("CNY"),
|
||||
}}
|
||||
|
||||
var got Config
|
||||
@@ -1044,7 +1022,7 @@ func TestRenderTOMLRoundTripsPerModelPrices(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("deepseek provider missing after round trip")
|
||||
}
|
||||
if p.Prices["deepseek-v4-flash"].Input != 1.5 || p.Prices["deepseek-v4-pro"].Output != 13.5 {
|
||||
if p.Prices[DeepSeekFlashModel].Input != 1 || p.Prices[deepSeekProModel].Output != 13.5 {
|
||||
t.Fatalf("prices after round trip = %+v", p.Prices)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
// roles.go — the model refs a role assignment can name.
|
||||
package config
|
||||
|
||||
// roleModelRefs lists every model a role points at. Both the fallback repair and
|
||||
// the referenced-provider scan walk this set, and they have to walk the same one:
|
||||
// a role missing from either keeps a ref alive that no provider can resolve.
|
||||
func (c *Config) roleModelRefs() []string {
|
||||
// roleModelRefTargets lists every field a role points a model at. The fallback
|
||||
// repair, the referenced-provider scan and the retired-model rename all walk
|
||||
// this set, and they have to walk the same one: a role missing from any of them
|
||||
// keeps a ref alive that no provider can resolve.
|
||||
func (c *Config) roleModelRefTargets() []*string {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return []string{c.Agent.PlannerModel, c.Agent.SubagentModel, c.Agent.VisionModel}
|
||||
return []*string{
|
||||
&c.Agent.PlannerModel, &c.Agent.SubagentModel, &c.Agent.VisionModel,
|
||||
&c.Agent.GuardianModel, &c.Agent.RecoveryModel, &c.Agent.TriageModel,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) roleModelRefs() []string {
|
||||
targets := c.roleModelRefTargets()
|
||||
out := make([]string, 0, len(targets))
|
||||
for _, ref := range targets {
|
||||
out = append(out, *ref)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -13,9 +13,11 @@ var mimoVisionModels = map[string]bool{
|
||||
"mimo-v2-omni": true,
|
||||
}
|
||||
|
||||
// InferVisionModels returns model IDs that look like chat models with image-input
|
||||
// support. It is intentionally conservative and meant for Settings defaults; an
|
||||
// explicit provider vision_models list remains the source of truth.
|
||||
// InferVisionModels returns model IDs that look like chat models with image
|
||||
// input. It is the last resort: an explicit vision_models list answers first, a
|
||||
// curated preset covering the address answers next, and reading a name is what
|
||||
// is left when nothing has been declared — so it stays conservative, and stays
|
||||
// a suggestion its user can correct rather than a claim about a vendor.
|
||||
func InferVisionModels(models []string) []string {
|
||||
out := make([]string, 0, len(models))
|
||||
seen := map[string]bool{}
|
||||
|
||||
@@ -40,18 +40,21 @@ func TestDeepSeekVisionIsAnsweredPerModel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
seeing := entry("deepseek-v4-flash-vision-exp")
|
||||
if !CanConfigureVision(seeing) {
|
||||
t.Fatal("the declared image-taking model must be configurable")
|
||||
}
|
||||
if !EffectiveVision(seeing) {
|
||||
t.Fatal("ticking it in the panel must settle it, as it does anywhere else")
|
||||
for _, model := range []string{DeepSeekFlashModel, "deepseek-v4-flash", "deepseek-v4-flash-vision-exp"} {
|
||||
seeing := entry(model)
|
||||
if !CanConfigureVision(seeing) {
|
||||
t.Fatalf("%s reads images; it must be configurable", model)
|
||||
}
|
||||
if !EffectiveVision(seeing) {
|
||||
t.Fatalf("ticking %s in the panel must settle it, as it does anywhere else", model)
|
||||
}
|
||||
}
|
||||
|
||||
for _, model := range []string{"deepseek-v4-flash", "deepseek-v4-pro"} {
|
||||
if CanConfigureVision(entry(model)) {
|
||||
t.Fatalf("%s takes text only; ticking it must stay a dead switch", model)
|
||||
}
|
||||
// Pro answers an image with 200 and no sight of it rather than refusing, so
|
||||
// the dead switch is the only thing standing between a user and a picture
|
||||
// that was charged for, sent, and never seen.
|
||||
if CanConfigureVision(entry(deepSeekProModel)) {
|
||||
t.Fatalf("%s takes text only; ticking it must stay a dead switch", deepSeekProModel)
|
||||
}
|
||||
|
||||
// The vendor's list is the declaration. A model id containing "vision" is a
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
const shellSeedConfig = `config_version = 6
|
||||
default_model = "deepseek-flash/deepseek-v4-flash"
|
||||
default_model = "deepseek-flash/deepseek-flash"
|
||||
|
||||
[[providers]]
|
||||
name = "deepseek-flash"
|
||||
@@ -48,7 +48,7 @@ func TestSaveShellSettingsPersists(t *testing.T) {
|
||||
written, _ := os.ReadFile(path)
|
||||
t.Fatalf("provider block lost by the shell write; file now:\n%s", written)
|
||||
}
|
||||
if saved.DefaultModel != "deepseek-flash/deepseek-v4-flash" {
|
||||
if saved.DefaultModel != "deepseek-flash/deepseek-flash" {
|
||||
t.Fatalf("default_model = %q, want it preserved", saved.DefaultModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,8 +38,8 @@ func TestSlashArgItems(t *testing.T) {
|
||||
ServerNames: []string{"fs", "git"},
|
||||
ConfiguredMCP: []string{"fs", "linear"},
|
||||
DisconnectedMCP: []string{"optional"},
|
||||
ModelRefs: []string{"deepseek-flash/deepseek-v4-flash", "deepseek-pro/deepseek-v4-pro"},
|
||||
CurrentModel: "deepseek-flash/deepseek-v4-flash",
|
||||
ModelRefs: []string{"deepseek-flash/deepseek-flash", "deepseek-pro/deepseek-v4-pro"},
|
||||
CurrentModel: "deepseek-flash/deepseek-flash",
|
||||
ProviderNames: []string{"deepseek-flash", "deepseek-pro", "custom"},
|
||||
CurrentProvider: "deepseek-flash",
|
||||
PluginNames: []string{"superpowers", "workflow-kit"},
|
||||
|
||||
@@ -15,14 +15,14 @@ func TestToWireUsageDualWritesCostQuoteAndLegacyAliases(t *testing.T) {
|
||||
Kind: event.Usage,
|
||||
ModelRef: "deepseek-flash/deepseek-v4-flash",
|
||||
Usage: &provider.Usage{PromptTokens: 1_000_000, CompletionTokens: 1_000_000, TotalTokens: 2_000_000},
|
||||
Pricing: &provider.Pricing{CacheHit: 0.05, Input: 1.5, Output: 4.5, Currency: "¥"},
|
||||
Pricing: &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 4, Currency: "¥"},
|
||||
CostQuote: func() *billing.CostQuote {
|
||||
q := billing.BuildQuote(billing.QuoteInput{
|
||||
Usage: billing.UsageTokens{PromptTokens: 1_000_000, CompletionTokens: 1_000_000},
|
||||
Rates: billing.RateCard{CacheHit: 0.05, Input: 1.5, Output: 4.5, Currency: "CNY"},
|
||||
Rates: billing.RateCard{CacheHit: 0.02, Input: 1, Output: 4, Currency: "CNY"},
|
||||
DisplayCurrency: "USD",
|
||||
ProviderKind: "deepseek",
|
||||
ModelID: "deepseek-v4-flash",
|
||||
ModelID: "deepseek-flash",
|
||||
})
|
||||
return &q
|
||||
}(),
|
||||
|
||||
@@ -184,7 +184,8 @@ func TestOfficialDeepSeekVisionIsPerModel(t *testing.T) {
|
||||
want bool
|
||||
}{
|
||||
{"deepseek-v4-flash-vision-exp", true},
|
||||
{"deepseek-v4-flash", false},
|
||||
{"deepseek-flash", true},
|
||||
{"deepseek-v4-flash", true},
|
||||
{"deepseek-v4-pro", false},
|
||||
// A name is not a declaration: this model does not exist.
|
||||
{"deepseek-v5-vision", false},
|
||||
|
||||
@@ -38,11 +38,13 @@ func IsDeepSeek(baseURL string) bool {
|
||||
}
|
||||
|
||||
// deepSeekImageModels are the official DeepSeek models that accept image input.
|
||||
// Declared, not inferred: the host serves text-only and image-taking models
|
||||
// side by side, and nothing on the wire distinguishes them before a request is
|
||||
// rejected. A name is not evidence — "vision" in a model id is a word, and the
|
||||
// list is what the vendor documents.
|
||||
// Declared, not inferred: measured 2026-09-13 on both the OpenAI- and the
|
||||
// Anthropic-shaped endpoint, the flash names answer from the pixels while pro
|
||||
// takes the same image, returns 200 and answers as if it saw nothing. A refusal
|
||||
// would have been evidence; a name is not, and nothing here spells vision.
|
||||
var deepSeekImageModels = map[string]bool{
|
||||
"deepseek-flash": true,
|
||||
"deepseek-v4-flash": true,
|
||||
"deepseek-v4-flash-vision-exp": true,
|
||||
}
|
||||
|
||||
|
||||
@@ -329,10 +329,12 @@ func TestOfficialDeepSeekVisionModelSendsImageParts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The sibling models on the same host keep refusing, so the change is the model
|
||||
// list and not the endpoint guard coming off.
|
||||
// Pro on the same host stays untickable, so the change is the model list and
|
||||
// not the endpoint guard coming off. It does not refuse an image — measured
|
||||
// 2026-09-13, it returns 200 and answers as if it saw nothing — which is what
|
||||
// makes the guard the only thing that can tell a user their picture was lost.
|
||||
func TestOfficialDeepSeekTextModelsStillRefuseImages(t *testing.T) {
|
||||
for _, model := range []string{"deepseek-v4-flash", "deepseek-v4-pro"} {
|
||||
for _, model := range []string{"deepseek-v4-pro"} {
|
||||
p, err := New(provider.Config{
|
||||
Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: model,
|
||||
Extra: map[string]any{"vision": true},
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
func TestOfficialDeepSeekResponsesImageMetadataMatchesTextOnlyWireBytes(t *testing.T) {
|
||||
c := New(Config{
|
||||
Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash",
|
||||
Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-pro",
|
||||
Extra: map[string]any{"vision": true},
|
||||
}).(*client)
|
||||
plain := []provider.Message{
|
||||
|
||||
@@ -975,11 +975,11 @@ func TestOfficialDeepSeekResponsesVisionIsModelScoped(t *testing.T) {
|
||||
Extra: map[string]any{"vision": true},
|
||||
}).(*client)
|
||||
}
|
||||
plain := makeClient("deepseek-v4-flash")
|
||||
plain := makeClient("deepseek-v4-pro")
|
||||
if plain.vision {
|
||||
t.Fatal("ordinary DeepSeek Flash must stay text-only despite stale vision metadata")
|
||||
t.Fatal("DeepSeek Pro must stay text-only despite stale vision metadata")
|
||||
}
|
||||
vision := makeClient("deepseek-v4-flash-vision-exp")
|
||||
vision := makeClient("deepseek-flash")
|
||||
if !vision.vision {
|
||||
t.Fatal("the documented DeepSeek Vision model must accept Responses input_image parts")
|
||||
}
|
||||
|
||||
@@ -43,10 +43,10 @@ api_key_env = "DEEPSEEK_API_KEY"
|
||||
written, _ := os.ReadFile(path)
|
||||
t.Fatalf("provider block lost by the approval write; file now:\n%s", written)
|
||||
}
|
||||
if entry.APIKeyEnv != "DEEPSEEK_API_KEY" || entry.Default != "deepseek-v4-flash" {
|
||||
if entry.APIKeyEnv != "DEEPSEEK_API_KEY" || entry.Default != "deepseek-flash" {
|
||||
t.Fatalf("provider entry damaged: %+v", entry)
|
||||
}
|
||||
if saved.DefaultModel != "deepseek-flash/deepseek-v4-flash" {
|
||||
if saved.DefaultModel != "deepseek-flash/deepseek-flash" {
|
||||
t.Fatalf("default_model = %q, want it preserved", saved.DefaultModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ show_turn_usage = true # CLI/TUI: show per-request token and cost receipts in
|
||||
[desktop]
|
||||
# language = "auto" # auto|en|zh; auto follows the browser/OS locale
|
||||
# currency = "auto" # legacy display currency; prefer [billing].display_currency
|
||||
layout_style = "classic" # classic|workbench|creation; desktop layout style
|
||||
# theme = "auto" # desktop only: auto|dark|light
|
||||
# terminal_theme = "auto" # integrated terminal: auto|dark|light; auto follows the desktop app
|
||||
# theme_style = "graphite" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases
|
||||
@@ -68,8 +67,11 @@ compact_ratio = 0.85 # sole auto trigger; presets 0.70 (active) / 0.80 (s
|
||||
name = "deepseek"
|
||||
kind = "openai"
|
||||
base_url = "https://api.deepseek.com"
|
||||
models = ["deepseek-v4-flash", "deepseek-v4-pro"]
|
||||
default = "deepseek-v4-flash" # optional; defaults to the first of `models`
|
||||
models = ["deepseek-flash", "deepseek-v4-pro"]
|
||||
default = "deepseek-flash" # optional; defaults to the first of `models`
|
||||
# deepseek-flash reads images; deepseek-v4-pro takes them and answers as if it
|
||||
# saw nothing, so listing it here would drop pictures silently.
|
||||
vision_models = ["deepseek-flash"]
|
||||
api_key_env = "DEEPSEEK_API_KEY"
|
||||
context_window = 1000000
|
||||
# Per-turn total output budget (does not change compact_ratio):
|
||||
@@ -78,13 +80,13 @@ context_window = 1000000
|
||||
# max_output_tokens = 65536 # heavy reasoning / long tool loops
|
||||
# max_output_tokens = 131072 # only after repeated finish_reason=length
|
||||
# Optional per-model context budgets. Omitted models inherit context_window.
|
||||
# model_overrides = { "deepseek-v4-flash" = { context_window = 1000000 } }
|
||||
# model_overrides = { "deepseek-flash" = { context_window = 1000000 } }
|
||||
# Official list prices are frozen per provider (billing_currency). Display
|
||||
# currency ([billing].display_currency) never rewrites these tables.
|
||||
# billing_currency = "USD" # frozen list-price currency: CNY|USD
|
||||
# billing_mode = "payg" # payg | subscription_equivalent (e.g. MiMo Token Plan)
|
||||
# Set prices explicitly only when you want to pin custom rates (always protected).
|
||||
# prices = { "deepseek-v4-flash" = { cache_hit = 0.02, input = 1, output = 2, currency = "¥" }, "deepseek-v4-pro" = { cache_hit = 0.025, input = 3, output = 6, currency = "¥" } } # per 1M tokens
|
||||
# prices = { "deepseek-flash" = { cache_hit = 0.02, input = 1, output = 2, currency = "¥" }, "deepseek-v4-pro" = { cache_hit = 0.025, input = 3, output = 6, currency = "¥" } } # per 1M tokens
|
||||
# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).
|
||||
effort = "high"
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// pricecheck compares the rates in billing's table against what each vendor
|
||||
// publishes today, and reports how long ago a person last confirmed them.
|
||||
//
|
||||
// It reports, it does not gate: it reaches the network, and a page changing
|
||||
// shape is something to look at rather than a build to fail. What it must never
|
||||
// do is answer "agrees" for a page it could not read — that is the one failure
|
||||
// mode a price checker cannot have, so unread and unreadable are their own
|
||||
// verdicts and neither counts as agreement.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/billing"
|
||||
)
|
||||
|
||||
type verdict string
|
||||
|
||||
const (
|
||||
agrees verdict = "agrees"
|
||||
differs verdict = "DIFFERS"
|
||||
unread verdict = "unread"
|
||||
unreadable verdict = "UNREADABLE"
|
||||
)
|
||||
|
||||
func main() {
|
||||
timeout := flag.Duration("timeout", 30*time.Second, "per-page fetch timeout")
|
||||
offline := flag.String("offline", "", "read pages from this directory instead of the network")
|
||||
flag.Parse()
|
||||
|
||||
failed := false
|
||||
for _, s := range sources() {
|
||||
fmt.Printf("%s/%s %s\n", s.Provider, s.Currency, s.URL)
|
||||
if s.Read == nil {
|
||||
fmt.Printf(" %-11s %s\n", unread, s.Unread)
|
||||
continue
|
||||
}
|
||||
rates, err := readSource(s, *offline, *timeout)
|
||||
if err != nil {
|
||||
fmt.Printf(" %-11s %v\n", unreadable, err)
|
||||
failed = true
|
||||
continue
|
||||
}
|
||||
for _, got := range rates {
|
||||
if reportModel(s, got) == differs {
|
||||
failed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
reportCheckedOn()
|
||||
if failed {
|
||||
fmt.Fprintln(os.Stderr, "\npricecheck: a page disagrees with the table or could not be read.")
|
||||
fmt.Fprintln(os.Stderr, "A price change is an append to officialRates, never an edit — the")
|
||||
fmt.Fprintln(os.Stderr, "superseded rate is what lets an installed config be brought forward.")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func readSource(s source, offline string, timeout time.Duration) ([]published, error) {
|
||||
body, err := fetch(s, offline, timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer body.Close()
|
||||
tables, err := parseTables(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(tables) == 0 {
|
||||
return nil, fmt.Errorf("the page states no table")
|
||||
}
|
||||
return s.Read(tables[0], s.Models)
|
||||
}
|
||||
|
||||
func fetch(s source, offline string, timeout time.Duration) (io.ReadCloser, error) {
|
||||
if offline != "" {
|
||||
return os.Open(filepath.Join(offline, offlineName(s)))
|
||||
}
|
||||
client := &http.Client{Timeout: timeout}
|
||||
resp, err := client.Get(s.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// offlineName is the fixture a page is read from with -offline, and the name to
|
||||
// save one under: the vendor, its currency, and nothing that can collide.
|
||||
func offlineName(s source) string {
|
||||
return strings.ToLower(s.Provider + "-" + s.Currency + ".html")
|
||||
}
|
||||
|
||||
func reportModel(s source, got published) verdict {
|
||||
want := billing.CurrentRate(s.Provider, got.Model, s.Currency)
|
||||
if want == nil {
|
||||
fmt.Printf(" %-11s %s is priced by the page and by nothing local\n", differs, got.Model)
|
||||
return differs
|
||||
}
|
||||
if got.Rate.CacheHit == want.CacheHit && got.Rate.Input == want.Input && got.Rate.Output == want.Output {
|
||||
fmt.Printf(" %-11s %-18s %g / %g / %g\n", agrees, got.Model, want.CacheHit, want.Input, want.Output)
|
||||
return agrees
|
||||
}
|
||||
fmt.Printf(" %-11s %-18s page %g / %g / %g, table %g / %g / %g\n", differs, got.Model,
|
||||
got.Rate.CacheHit, got.Rate.Input, got.Rate.Output, want.CacheHit, want.Input, want.Output)
|
||||
return differs
|
||||
}
|
||||
|
||||
// reportCheckedOn says how stale each vendor's last human confirmation is. No
|
||||
// threshold: a number nobody chose is one nobody can defend, and the date is
|
||||
// what a reader needs to decide whether to go and look.
|
||||
func reportCheckedOn() {
|
||||
oldest := map[string]string{}
|
||||
for _, entry := range billing.OfficialCatalog() {
|
||||
if current, seen := oldest[entry.Provider]; !seen || entry.CheckedOn < current {
|
||||
oldest[entry.Provider] = entry.CheckedOn
|
||||
}
|
||||
}
|
||||
providers := make([]string, 0, len(oldest))
|
||||
for provider := range oldest {
|
||||
providers = append(providers, provider)
|
||||
}
|
||||
sort.Strings(providers)
|
||||
fmt.Println("\nlast confirmed by a person:")
|
||||
for _, provider := range providers {
|
||||
fmt.Printf(" %-10s %s%s\n", provider, oldest[provider], ageOf(oldest[provider]))
|
||||
}
|
||||
}
|
||||
|
||||
func ageOf(checkedOn string) string {
|
||||
when, err := time.Parse("2006-01-02", checkedOn)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" (%d days ago)", int(time.Since(when).Hours()/24))
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/billing"
|
||||
)
|
||||
|
||||
func tableFrom(t *testing.T, path string) table {
|
||||
t.Helper()
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
tables, err := parseTables(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tables) == 0 {
|
||||
t.Fatalf("%s holds no table", path)
|
||||
}
|
||||
return tables[0]
|
||||
}
|
||||
|
||||
// The numbers are the ones the saved pages state. They are asserted literally
|
||||
// rather than against billing's table: a fixture is a snapshot of what a vendor
|
||||
// published, and comparing it to today's rates is what pricecheck does live.
|
||||
func TestReadersTakeTheRatesThePagesState(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
path string
|
||||
models []string
|
||||
read func(table, []string) ([]published, error)
|
||||
want []published
|
||||
}{
|
||||
{
|
||||
name: "deepseek prices two models off-peak", path: "testdata/deepseek-usd.html",
|
||||
models: []string{"deepseek-flash", "deepseek-v4-pro"}, read: readDeepSeek,
|
||||
want: []published{
|
||||
{Model: "deepseek-flash", Rate: card(0.003, 0.15, 0.6)},
|
||||
{Model: "deepseek-v4-pro", Rate: card(0.022, 0.66, 1.98)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "longcat states the discounted column last", path: "testdata/longcat-usd.html",
|
||||
models: []string{"LongCat-2.0"}, read: readLongCat,
|
||||
want: []published{{Model: "LongCat-2.0", Rate: card(0.006, 0.30, 1.20)}},
|
||||
},
|
||||
{
|
||||
name: "longcat in the other language", path: "testdata/longcat-cny.html",
|
||||
models: []string{"LongCat-2.0"}, read: readLongCat,
|
||||
want: []published{{Model: "LongCat-2.0", Rate: card(0.04, 2, 8)}},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := tc.read(tableFrom(t, tc.path), tc.models)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("read %d rates, want %d", len(got), len(tc.want))
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Errorf("rate %d = %+v, want %+v", i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// "Cached Input" is a substring of "Uncached Input". A reader that matches on
|
||||
// containment takes the uncached rate for both and reports agreement on a
|
||||
// number the vendor never published for that line.
|
||||
func TestCachedAndUncachedRowsAreNotConfused(t *testing.T) {
|
||||
got, err := readLongCat(tableFrom(t, "testdata/longcat-usd.html"), []string{"LongCat-2.0"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got[0].Rate.CacheHit == got[0].Rate.Input {
|
||||
t.Fatalf("cache-hit and input read the same row: %+v", got[0].Rate)
|
||||
}
|
||||
}
|
||||
|
||||
// Every way of failing to read a page has to be an error. A reader that returns
|
||||
// zeroes turns "the page changed" into "the vendor now charges nothing", and a
|
||||
// checker that reports agreement on that is worse than having none.
|
||||
func TestUnreadablePagesAreErrorsAndNotZeroes(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
html string
|
||||
read func(table, []string) ([]published, error)
|
||||
}{
|
||||
{"deepseek without a model row", `<table><tr><td>Price</td><td>$1</td></tr></table>`, readDeepSeek},
|
||||
{"deepseek naming other models", `<table><tr><td>deepseek-flash</td></tr>
|
||||
<tr><td>cache hit</td><td>off-peak</td><td>$1</td></tr></table>`, readDeepSeek},
|
||||
{"longcat with a renamed row", `<table><tr><td>Input</td><td>$1</td></tr>
|
||||
<tr><td>Output</td><td>$2</td></tr></table>`, readLongCat},
|
||||
{"longcat with no amount", `<table><tr><td>Cached Input</td><td>free</td></tr>
|
||||
<tr><td>Uncached Input</td><td>free</td></tr><tr><td>Output</td><td>free</td></tr></table>`, readLongCat},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tables, err := parseTables(strings.NewReader(tc.html))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
models := []string{"deepseek-flash", "deepseek-v4-pro"}
|
||||
if tc.read == nil || len(tables) == 0 {
|
||||
t.Fatal("fixture did not parse")
|
||||
}
|
||||
if strings.Contains(tc.name, "longcat") {
|
||||
models = []string{"LongCat-2.0"}
|
||||
}
|
||||
if got, err := tc.read(tables[0], models); err == nil {
|
||||
t.Fatalf("read succeeded on an unreadable page: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Two rows carrying the same marker mean the page grew a section this reader
|
||||
// cannot tell apart; picking either is guessing.
|
||||
func TestAmbiguousRowsDoNotResolve(t *testing.T) {
|
||||
tables, _ := parseTables(strings.NewReader(
|
||||
`<table><tr><td>Output</td><td>$1</td></tr><tr><td>Output</td><td>$2</td></tr></table>`))
|
||||
if _, ok := tables[0].rowWithCell("Output"); ok {
|
||||
t.Fatal("an ambiguous page resolved to one row")
|
||||
}
|
||||
if _, ok := tables[0].rowContaining("output"); ok {
|
||||
t.Fatal("an ambiguous page resolved to one row")
|
||||
}
|
||||
}
|
||||
|
||||
func card(cacheHit, input, output float64) billing.RateCard {
|
||||
return billing.RateCard{CacheHit: cacheHit, Input: input, Output: output}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// sources.go — where each vendor publishes its rates, and how to read them off.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/billing"
|
||||
)
|
||||
|
||||
// published is one rate a vendor's page states for a model.
|
||||
type published struct {
|
||||
Model string
|
||||
Rate billing.RateCard
|
||||
}
|
||||
|
||||
// source is one vendor's price page in one currency. Read is nil where no
|
||||
// reader exists and Unread then says why: a source nobody can read reports as
|
||||
// unchecked, never as agreeing, because agreeing is a claim about the vendor.
|
||||
type source struct {
|
||||
Provider string
|
||||
Currency string
|
||||
Models []string
|
||||
URL string
|
||||
Read func(t table, models []string) ([]published, error)
|
||||
Unread string
|
||||
}
|
||||
|
||||
func sources() []source {
|
||||
return []source{
|
||||
{
|
||||
Provider: "deepseek", Currency: "USD",
|
||||
Models: []string{"deepseek-flash", "deepseek-v4-pro"},
|
||||
URL: billing.DocDeepSeekPricing,
|
||||
Read: readDeepSeek,
|
||||
},
|
||||
{
|
||||
Provider: "longcat", Currency: "USD",
|
||||
Models: []string{"LongCat-2.0"},
|
||||
URL: billing.DocLongCatPricingUSD,
|
||||
Read: readLongCat,
|
||||
},
|
||||
{
|
||||
Provider: "longcat", Currency: "CNY",
|
||||
Models: []string{"LongCat-2.0"},
|
||||
URL: billing.DocLongCatPricingCNY,
|
||||
Read: readLongCat,
|
||||
},
|
||||
{
|
||||
Provider: "mimo", Currency: "CNY",
|
||||
Models: []string{"mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-flash"},
|
||||
URL: billing.DocMiMoPAYG,
|
||||
Unread: "the page renders its prices in the browser; fetching it returns no table",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// readDeepSeek reads the model columns off the pricing table. The wanted rates
|
||||
// are the off-peak ones, which sit on the row that names them; the peak row
|
||||
// below carries no label of its own and is never asked for.
|
||||
func readDeepSeek(t table, models []string) ([]published, error) {
|
||||
// Matched on the names themselves: "deepseek-" alone also picks up the
|
||||
// model-version row, which spells the same models a different way.
|
||||
stated, ok := t.rowContaining(models...)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no single row naming %v", models)
|
||||
}
|
||||
if named := modelNames(stated); !equalStrings(named, models) {
|
||||
return nil, fmt.Errorf("page prices %v, the local table expects %v", named, models)
|
||||
}
|
||||
cacheHit, err := amountsIn(t, models, "cache hit", "off-peak")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
input, err := amountsIn(t, models, "cache miss", "off-peak")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output, err := amountsIn(t, models, "output tokens", "off-peak")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]published, 0, len(models))
|
||||
for i, model := range models {
|
||||
out = append(out, published{Model: model, Rate: billing.RateCard{
|
||||
CacheHit: cacheHit[i], Input: input[i], Output: output[i],
|
||||
}})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// readLongCat takes the last amount on each row, which is the discounted column
|
||||
// the local table records. A page that stops discounting drops that column, and
|
||||
// the last amount is then the list price — which is what should be compared.
|
||||
func readLongCat(t table, models []string) ([]published, error) {
|
||||
if len(models) != 1 {
|
||||
return nil, fmt.Errorf("this page prices one model, the local table expects %d", len(models))
|
||||
}
|
||||
rate := billing.RateCard{}
|
||||
// Labelled cells, in both languages the vendor publishes. A label it stops
|
||||
// using reads as unreadable, which is the point: the alternative is a
|
||||
// checker that quietly compares nothing and reports agreement.
|
||||
for _, field := range []struct {
|
||||
into *float64
|
||||
labels []string
|
||||
}{
|
||||
{&rate.CacheHit, []string{"Cached Input", "输入(命中缓存)"}},
|
||||
{&rate.Input, []string{"Uncached Input", "输入(未命中缓存)"}},
|
||||
{&rate.Output, []string{"Output", "输出"}},
|
||||
} {
|
||||
row, ok := t.rowWithCell(field.labels...)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no single row labelled %v", field.labels)
|
||||
}
|
||||
amounts := amountsOf(row)
|
||||
if len(amounts) == 0 {
|
||||
return nil, fmt.Errorf("row %v states no amount", field.labels)
|
||||
}
|
||||
*field.into = amounts[len(amounts)-1]
|
||||
}
|
||||
return []published{{Model: models[0], Rate: rate}}, nil
|
||||
}
|
||||
|
||||
// amountsIn finds the row carrying every marker and requires one amount per
|
||||
// model. A count that does not match means the page changed shape, which is
|
||||
// reported rather than trimmed to fit.
|
||||
func amountsIn(t table, models []string, markers ...string) ([]float64, error) {
|
||||
row, ok := t.rowContaining(markers...)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no single row for %v", markers)
|
||||
}
|
||||
amounts := amountsOf(row)
|
||||
if len(amounts) != len(models) {
|
||||
return nil, fmt.Errorf("row %v states %d amounts for %d models", markers, len(amounts), len(models))
|
||||
}
|
||||
return amounts, nil
|
||||
}
|
||||
|
||||
func modelNames(row []string) []string {
|
||||
var out []string
|
||||
for _, cell := range row {
|
||||
name, _, _ := strings.Cut(cell, "(")
|
||||
if name = strings.TrimSpace(name); strings.HasPrefix(name, "deepseek-") {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func amountsOf(row []string) []float64 {
|
||||
var out []float64
|
||||
for _, cell := range row {
|
||||
if amount, ok := parseAmount(cell); ok {
|
||||
out = append(out, amount)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseAmount accepts the symbols these pages price in. A cell carrying
|
||||
// anything else is not an amount, which is how a label is told from a rate.
|
||||
func parseAmount(cell string) (float64, bool) {
|
||||
cell = strings.TrimSpace(cell)
|
||||
for _, symbol := range []string{"$", "¥", "¥", "US$"} {
|
||||
cell = strings.TrimPrefix(cell, symbol)
|
||||
}
|
||||
cell = strings.TrimSuffix(strings.TrimSpace(cell), "元")
|
||||
amount, err := strconv.ParseFloat(strings.TrimSpace(cell), 64)
|
||||
return amount, err == nil
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// table.go — reading a published price page as rows of cells.
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// table is one HTML table as rows of trimmed cell text. Spans are not expanded:
|
||||
// a reader here locates a value by the label on its own row, never by counting
|
||||
// columns, because a vendor adding a column would silently shift every index.
|
||||
type table [][]string
|
||||
|
||||
// parseTables returns every table on the page, in document order.
|
||||
func parseTables(r io.Reader) ([]table, error) {
|
||||
doc, err := html.Parse(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tables []table
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && n.Data == "table" {
|
||||
tables = append(tables, rowsOf(n))
|
||||
return
|
||||
}
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
func rowsOf(node *html.Node) table {
|
||||
var rows table
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && n.Data == "tr" {
|
||||
rows = append(rows, cellsOf(n))
|
||||
return
|
||||
}
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(node)
|
||||
return rows
|
||||
}
|
||||
|
||||
func cellsOf(node *html.Node) []string {
|
||||
var cells []string
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && (n.Data == "td" || n.Data == "th") {
|
||||
cells = append(cells, strings.Join(strings.Fields(textOf(n)), " "))
|
||||
return
|
||||
}
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(node)
|
||||
return cells
|
||||
}
|
||||
|
||||
func textOf(n *html.Node) string {
|
||||
if n.Type == html.TextNode {
|
||||
return n.Data
|
||||
}
|
||||
var b strings.Builder
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
b.WriteString(textOf(child))
|
||||
b.WriteString(" ")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// rowWithCell finds the one row holding a cell equal to a label. Equality, not
|
||||
// containment: "Cached Input" is a substring of "Uncached Input", and a reader
|
||||
// that cannot tell those apart reports the wrong half of a price table.
|
||||
func (t table) rowWithCell(labels ...string) ([]string, bool) {
|
||||
var found []string
|
||||
for _, row := range t {
|
||||
matched := false
|
||||
for _, cell := range row {
|
||||
for _, label := range labels {
|
||||
if strings.EqualFold(strings.TrimSpace(cell), label) {
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
if found != nil {
|
||||
return nil, false
|
||||
}
|
||||
found = row
|
||||
}
|
||||
return found, found != nil
|
||||
}
|
||||
|
||||
// rowContaining finds the one row whose cells carry every marker. Requiring a
|
||||
// single match is the point: two matching rows means the page grew a section
|
||||
// this reader cannot tell apart, and guessing between them is how a checker
|
||||
// starts reporting a rate nobody publishes.
|
||||
func (t table) rowContaining(markers ...string) ([]string, bool) {
|
||||
var found []string
|
||||
for _, row := range t {
|
||||
joined := strings.ToLower(strings.Join(row, " "))
|
||||
matched := true
|
||||
for _, marker := range markers {
|
||||
if !strings.Contains(joined, strings.ToLower(marker)) {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
if found != nil {
|
||||
return nil, false
|
||||
}
|
||||
found = row
|
||||
}
|
||||
return found, found != nil
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
<table style="text-align:center"><tr><td colspan="3" style="text-align:center">MODEL</td><td>deepseek-flash<sup>(1)</sup></td><td>deepseek-v4-pro<sup>(2)</sup></td></tr><tr><td colspan="3">BASE URL (OpenAI Format)</td><td colspan="2"><a href="https://api.deepseek.com" target="_blank" rel="noopener noreferrer">https://api.deepseek.com</a></td></tr><tr><td colspan="3">BASE URL (Anthropic Format)</td><td colspan="2"><a href="https://api.deepseek.com/anthropic" target="_blank" rel="noopener noreferrer">https://api.deepseek.com/anthropic</a></td></tr><tr><td colspan="3" style="text-align:center">MODEL VERSION</td><td>DeepSeek-V4.1-Flash</td><td>DeepSeek-V4-Pro-0813</td></tr><tr><td colspan="3">THINKING MODE</td><td colspan="2">Supports both non-thinking and thinking (default) modes<br>See <a href="/guides/thinking_mode">Thinking Mode</a> for how to switch</td></tr><tr><td colspan="3">CONTEXT LENGTH</td><td colspan="2">1M</td></tr><tr><td colspan="3">MAX OUTPUT</td><td colspan="2">MAXIMUM: 384K</td></tr><tr><td rowspan="7">FEATURES</td><td colspan="2"><a href="/guides/json_mode">Json Output</a></td><td>✓</td><td>✓</td></tr><tr><td colspan="2"><a href="/guides/tool_calls">Tool Calls</a></td><td>✓</td><td>✓</td></tr><tr><td colspan="2"><a href="/guides/responses_api">Responses API</a></td><td>✓</td><td>✓</td></tr><tr><td colspan="2"><a href="/guides/anthropic_api">Anthropic API</a></td><td>✓</td><td>✓</td></tr><tr><td colspan="2"><a href="/guides/chat_prefix_completion">Chat Prefix Completion(Beta)</a></td><td>✓</td><td>✓</td></tr><tr><td colspan="2"><a href="/guides/fim_completion">FIM Completion(Beta)</a></td><td>Non-thinking mode only</td><td>Non-thinking mode only</td></tr><tr><td colspan="2"><a href="/guides/vision">Vision</a></td><td>✓</td><td>Not supported</td></tr><tr><td rowspan="6">PRICING<sup>(3)</sup></td><td rowspan="2">1M INPUT TOKENS<br>(CACHE HIT)</td><td>OFF-PEAK</td><td>$0.003</td><td>$0.022</td></tr><tr><td>PEAK</td><td>$0.006</td><td>$0.044</td></tr><tr><td rowspan="2">1M INPUT TOKENS<br>(CACHE MISS)</td><td>OFF-PEAK</td><td>$0.15</td><td>$0.66</td></tr><tr><td>PEAK</td><td>$0.3</td><td>$1.32</td></tr><tr><td rowspan="2">1M OUTPUT TOKENS</td><td>OFF-PEAK</td><td>$0.6</td><td>$1.98</td></tr><tr><td>PEAK</td><td>$1.2</td><td>$3.96</td></tr><tr><td colspan="3">Concurrency Limit<sup>(4)</sup></td><td>2500</td><td>500</td></tr></table>
|
||||
+1
@@ -0,0 +1 @@
|
||||
<table tabindex="0"><thead><tr><th>计费项</th><th>原价 / 百万 Tokens</th><th>折扣价 / 百万 Tokens(限时)</th></tr></thead><tbody><tr><td>输入(未命中缓存)</td><td>¥5</td><td>¥2</td></tr><tr><td>输入(命中缓存)</td><td>¥0.10</td><td>¥0.04</td></tr><tr><td>输出</td><td>¥20</td><td>¥8</td></tr></tbody></table>
|
||||
+1
@@ -0,0 +1 @@
|
||||
<table tabindex="0"><thead><tr><th>Item</th><th>Price $/1M Tokens</th><th>Discounted Price $/1M Tokens (limited-time)</th></tr></thead><tbody><tr><td>Uncached Input</td><td>$0.75</td><td>$0.30</td></tr><tr><td>Cached Input</td><td>$0.015</td><td>$0.006</td></tr><tr><td>Output</td><td>$2.95</td><td>$1.20</td></tr></tbody></table>
|
||||
@@ -1,18 +1,20 @@
|
||||
{
|
||||
"limits": {
|
||||
"banner": 0,
|
||||
"build-artifact": 0,
|
||||
"claude-dialect": 1,
|
||||
"commented-code": 0,
|
||||
"complexity": 1512,
|
||||
"complexity": 1496,
|
||||
"error-text": 12,
|
||||
"essay": 3242,
|
||||
"file-size": 38938,
|
||||
"essay": 3218,
|
||||
"file-size": 38407,
|
||||
"flat-view": 0,
|
||||
"frontend-parity": 150,
|
||||
"function-size": 6595,
|
||||
"function-size": 6555,
|
||||
"layering": 0,
|
||||
"marker": 0,
|
||||
"narrative": 57,
|
||||
"orphan": 123,
|
||||
"orphan": 122,
|
||||
"refusal-path": 0,
|
||||
"spec-parity": 0,
|
||||
"struct-state": 41,
|
||||
@@ -94,7 +96,7 @@
|
||||
"internal/agent/agent.go": {
|
||||
"complexity": 37,
|
||||
"essay": 45,
|
||||
"file-size": 1398,
|
||||
"file-size": 1386,
|
||||
"function-size": 102
|
||||
},
|
||||
"internal/agent/agent_options.go": {
|
||||
@@ -145,8 +147,8 @@
|
||||
"essay": 5
|
||||
},
|
||||
"internal/agent/execute_batch.go": {
|
||||
"complexity": 25,
|
||||
"function-size": 128
|
||||
"complexity": 22,
|
||||
"function-size": 108
|
||||
},
|
||||
"internal/agent/execute_one.go": {
|
||||
"essay": 14
|
||||
@@ -211,9 +213,6 @@
|
||||
"internal/agent/recovery_gc_test.go": {
|
||||
"essay": 1
|
||||
},
|
||||
"internal/agent/review_report.go": {
|
||||
"essay": 1
|
||||
},
|
||||
"internal/agent/review_unavailable.go": {
|
||||
"orphan": 1
|
||||
},
|
||||
@@ -281,13 +280,13 @@
|
||||
},
|
||||
"internal/agent/subagent_store.go": {
|
||||
"essay": 5,
|
||||
"file-size": 149
|
||||
"file-size": 130
|
||||
},
|
||||
"internal/agent/task.go": {
|
||||
"complexity": 22,
|
||||
"essay": 50,
|
||||
"file-size": 1167,
|
||||
"function-size": 101,
|
||||
"complexity": 12,
|
||||
"essay": 35,
|
||||
"file-size": 1162,
|
||||
"function-size": 94,
|
||||
"orphan": 1
|
||||
},
|
||||
"internal/agent/task_test.go": {
|
||||
@@ -428,7 +427,7 @@
|
||||
},
|
||||
"internal/checkpoint/checkpoint.go": {
|
||||
"essay": 8,
|
||||
"file-size": 305
|
||||
"file-size": 291
|
||||
},
|
||||
"internal/checkpoint/observer.go": {
|
||||
"essay": 1
|
||||
@@ -457,10 +456,10 @@
|
||||
"essay": 15
|
||||
},
|
||||
"internal/cli/chat_tui.go": {
|
||||
"complexity": 325,
|
||||
"complexity": 324,
|
||||
"essay": 109,
|
||||
"file-size": 4676,
|
||||
"function-size": 1283
|
||||
"file-size": 4665,
|
||||
"function-size": 1277
|
||||
},
|
||||
"internal/cli/chat_tui_paste.go": {
|
||||
"essay": 9
|
||||
@@ -615,7 +614,7 @@
|
||||
},
|
||||
"internal/config/config.go": {
|
||||
"essay": 50,
|
||||
"file-size": 1439,
|
||||
"file-size": 1417,
|
||||
"narrative": 2
|
||||
},
|
||||
"internal/config/credentials.go": {
|
||||
@@ -636,7 +635,7 @@
|
||||
},
|
||||
"internal/config/edit.go": {
|
||||
"essay": 10,
|
||||
"file-size": 1602,
|
||||
"file-size": 1586,
|
||||
"orphan": 2
|
||||
},
|
||||
"internal/config/effort.go": {
|
||||
@@ -648,8 +647,8 @@
|
||||
},
|
||||
"internal/config/load.go": {
|
||||
"essay": 24,
|
||||
"file-size": 1470,
|
||||
"function-size": 61,
|
||||
"file-size": 1221,
|
||||
"function-size": 58,
|
||||
"orphan": 2
|
||||
},
|
||||
"internal/config/main_test.go": {
|
||||
@@ -687,8 +686,7 @@
|
||||
},
|
||||
"internal/config/provider_presets.go": {
|
||||
"claude-dialect": 1,
|
||||
"file-size": 263,
|
||||
"orphan": 1
|
||||
"file-size": 219
|
||||
},
|
||||
"internal/config/render.go": {
|
||||
"complexity": 226,
|
||||
@@ -730,8 +728,8 @@
|
||||
"frontend-parity": 2
|
||||
},
|
||||
"internal/control/controller.go": {
|
||||
"essay": 57,
|
||||
"file-size": 2465,
|
||||
"essay": 56,
|
||||
"file-size": 2436,
|
||||
"frontend-parity": 50,
|
||||
"narrative": 4
|
||||
},
|
||||
@@ -815,7 +813,7 @@
|
||||
},
|
||||
"internal/control/refs.go": {
|
||||
"essay": 5,
|
||||
"file-size": 425,
|
||||
"file-size": 423,
|
||||
"frontend-parity": 2
|
||||
},
|
||||
"internal/control/rewind.go": {
|
||||
@@ -912,7 +910,7 @@
|
||||
"internal/evidence/evidence.go": {
|
||||
"complexity": 6,
|
||||
"essay": 34,
|
||||
"file-size": 1734
|
||||
"file-size": 1729
|
||||
},
|
||||
"internal/evidence/review_report.go": {
|
||||
"essay": 7
|
||||
@@ -1077,9 +1075,6 @@
|
||||
"internal/fileutil/replacefallback_windows_test.go": {
|
||||
"essay": 1
|
||||
},
|
||||
"internal/frontmatter/frontmatter.go": {
|
||||
"essay": 5
|
||||
},
|
||||
"internal/gitcmd/gitcmd.go": {
|
||||
"essay": 14
|
||||
},
|
||||
@@ -1210,7 +1205,7 @@
|
||||
},
|
||||
"internal/plugin/plugin.go": {
|
||||
"essay": 28,
|
||||
"file-size": 972,
|
||||
"file-size": 925,
|
||||
"function-size": 12,
|
||||
"orphan": 3
|
||||
},
|
||||
@@ -1240,7 +1235,7 @@
|
||||
"essay": 1
|
||||
},
|
||||
"internal/plugin/transport_stdio.go": {
|
||||
"essay": 12,
|
||||
"essay": 11,
|
||||
"file-size": 4
|
||||
},
|
||||
"internal/pluginpkg/claude_compat.go": {
|
||||
@@ -1270,10 +1265,10 @@
|
||||
"orphan": 1
|
||||
},
|
||||
"internal/provider/anthropic/anthropic.go": {
|
||||
"complexity": 42,
|
||||
"complexity": 40,
|
||||
"essay": 31,
|
||||
"file-size": 87,
|
||||
"function-size": 146,
|
||||
"file-size": 80,
|
||||
"function-size": 142,
|
||||
"struct-state": 5
|
||||
},
|
||||
"internal/provider/anthropic/anthropic_test.go": {
|
||||
@@ -1285,7 +1280,7 @@
|
||||
"internal/provider/openai/openai.go": {
|
||||
"complexity": 58,
|
||||
"essay": 32,
|
||||
"file-size": 517,
|
||||
"file-size": 515,
|
||||
"function-size": 245,
|
||||
"struct-state": 9
|
||||
},
|
||||
@@ -1297,7 +1292,7 @@
|
||||
},
|
||||
"internal/provider/provider.go": {
|
||||
"essay": 51,
|
||||
"file-size": 289
|
||||
"file-size": 274
|
||||
},
|
||||
"internal/provider/responses/responses.go": {
|
||||
"essay": 11,
|
||||
@@ -1459,7 +1454,7 @@
|
||||
},
|
||||
"internal/serve/serve.go": {
|
||||
"essay": 22,
|
||||
"file-size": 522
|
||||
"file-size": 503
|
||||
},
|
||||
"internal/serve/serve_lease_test.go": {
|
||||
"essay": 3
|
||||
@@ -1501,7 +1496,7 @@
|
||||
},
|
||||
"internal/skill/skill.go": {
|
||||
"essay": 7,
|
||||
"file-size": 573
|
||||
"file-size": 560
|
||||
},
|
||||
"internal/skill/tools.go": {
|
||||
"essay": 2
|
||||
@@ -1584,7 +1579,7 @@
|
||||
"essay": 3
|
||||
},
|
||||
"internal/tool/builtin/grep.go": {
|
||||
"essay": 2
|
||||
"essay": 1
|
||||
},
|
||||
"internal/tool/builtin/managed_config.go": {
|
||||
"essay": 2
|
||||
|
||||
Reference in new issue
Block a user