@dsh-plus/llm-pi 0.1.26 → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,6 +18,7 @@ import {
18
18
  } from '@dsh-plus/shared/client'
19
19
  import { type ReactElement, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
20
20
  import { type ConfigValue, fetchCatalog, refreshCatalog, type WireModelsDevStatus } from './api.ts'
21
+ import { installCompatFields } from './constants.ts'
21
22
  import {
22
23
  type Draft,
23
24
  draftFromValue,
@@ -26,15 +27,16 @@ import {
26
27
  type ProviderDraft,
27
28
  toPatch,
28
29
  } from './draft.ts'
30
+ import type { Translate } from './i18n.ts'
29
31
  import { ProvidersSection } from './views/providers.tsx'
30
32
 
31
33
  export interface CardProps {
32
- t(key: string): string
34
+ t: Translate
33
35
  scope: Scope
34
36
  api: NamespaceSettingsApi
35
37
  }
36
38
 
37
- function modelsDevText(status: WireModelsDevStatus | null, t: (key: string) => string): string {
39
+ function modelsDevText(status: WireModelsDevStatus | null, t: Translate): string {
38
40
  if (status === null) return t('modelsDevEmpty')
39
41
  if (status.error !== null) return `${t('modelsDevError')}${status.error}`
40
42
  if (status.fetchedAt === null) return t('modelsDevEmpty')
@@ -64,6 +66,7 @@ export function LlmPiCard(props: CardProps): ReactElement | null {
64
66
  }, [value, draft])
65
67
 
66
68
  // 运行期诊断行(kitSource / models-dev 状态)来自模型目录端点,非配置数据。
69
+ // compat 字段表同批下发:安装后 UI 渲染与服务端校验同源(不再手抄镜像表)。
67
70
  useEffect(() => {
68
71
  let alive = true
69
72
  fetchCatalog('', 'models-dev')
@@ -71,6 +74,7 @@ export function LlmPiCard(props: CardProps): ReactElement | null {
71
74
  if (!alive) return
72
75
  setKitSource(result.kitSource ?? null)
73
76
  setModelsDevStatus(result.status ?? null)
77
+ if (result.compat !== undefined) installCompatFields(result.compat.fields)
74
78
  })
75
79
  .catch(() => {})
76
80
  return () => {
@@ -18,11 +18,11 @@ import type { Context } from '@deepseek-ai/cordis'
18
18
  import {
19
19
  createNamespaceApi,
20
20
  createSettingsScope,
21
- type SettingsRemoteFace,
21
+ type PluginClientContext,
22
22
  } from '@dsh-plus/shared/client'
23
23
  import { SETTINGS_NS } from '../ns.ts'
24
24
  import { LlmPiCard } from './card.tsx'
25
- import { en, NS, zh } from './i18n.ts'
25
+ import { type DictKey, en, NS, zh } from './i18n.ts'
26
26
  import { injectStyle } from './styles.ts'
27
27
 
28
28
  export const name = 'dsh-plus-llm-pi'
@@ -30,28 +30,8 @@ export const name = 'dsh-plus-llm-pi'
30
30
  /** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
31
31
  export const inject = ['slots', 'locale', 'remote', 'remote.settings'] as const
32
32
 
33
- interface SlotsLike {
34
- inject(key: string, callback: () => unknown): unknown
35
- register(options: Record<string, unknown>, component: unknown): () => void
36
- }
37
-
38
- interface LocaleLike {
39
- register(ns: string, dict: { zh: Record<string, string>; en: Record<string, string> }): () => void
40
- bind(ns: string): (key: string) => string
41
- }
42
-
43
- interface RemoteLike {
44
- settings: SettingsRemoteFace
45
- $on(event: string, listener: (payload?: unknown) => void): () => void
46
- }
47
-
48
- interface ClientContext {
49
- slots: SlotsLike
50
- locale: LocaleLike
51
- get(key: 'remote'): RemoteLike
52
- on(event: string, listener: () => void): () => void
53
- effect(execute: () => () => void, label?: string): unknown
54
- }
33
+ /** 宿主窄面收编在 @dsh-plus/shared/client;TKey 传入本包 DictKey 使键名受检。 */
34
+ type ClientContext = PluginClientContext<DictKey>
55
35
 
56
36
  export function apply(ctx: Context): void {
57
37
  const c = ctx as unknown as ClientContext
@@ -27,69 +27,29 @@ export const TRANSPORT_OPTIONS = ['sse', 'websocket', 'websocket-cached', 'auto'
27
27
  /** thinkingBudgets 档位键(来源:config.ts thinkingBudgets)。 */
28
28
  export const BUDGET_KEYS = ['minimal', 'low', 'medium', 'high'] as const
29
29
 
30
- type CompatValue = 'boolean' | 'object' | readonly string[]
30
+ export type CompatValue = 'boolean' | 'integer' | 'number' | 'object' | readonly string[]
31
31
 
32
32
  /**
33
- * 逐协议 compat 字段表(来源:compat.ts,逐字段镜像官方 catalog.ts COMPAT_GATES)。
34
- * 只列 offer 字段(withhold 字段官方写时拒绝,UI 不提供);取值约束对齐官方
35
- * config.ts compatProfile schema。
33
+ * 逐协议 compat 字段表:**服务端推导结果经 /catalog 下发**,浏览器半不再手抄
34
+ * (手抄遗漏官方新增字段 = UI 不渲染 + 保存被后端拒绝;见 compat-gates.ts)。
35
+ * 未拿到服务端表时用 EMPTY(UI 只渲染提示行),不放内置副本兜底——宁可少画
36
+ * 控件,也不给出可能与官方不符的字段集。
36
37
  */
37
- const COMPAT_FIELDS: Record<string, Record<string, CompatValue>> = {
38
- 'openai-completions': {
39
- supportsStore: 'boolean',
40
- supportsDeveloperRole: 'boolean',
41
- supportsReasoningEffort: 'boolean',
42
- supportsUsageInStreaming: 'boolean',
43
- supportsFinishReason: 'boolean',
44
- maxTokensField: ['max_completion_tokens', 'max_tokens'],
45
- requiresToolResultName: 'boolean',
46
- requiresAssistantAfterToolResult: 'boolean',
47
- requiresThinkingAsText: 'boolean',
48
- requiresReasoningContentOnAssistantMessages: 'boolean',
49
- thinkingFormat: [
50
- 'openai',
51
- 'deepseek',
52
- 'openrouter',
53
- 'together',
54
- 'baseten',
55
- 'zai',
56
- 'qwen',
57
- 'chat-template',
58
- 'qwen-chat-template',
59
- 'string-thinking',
60
- 'ant-ling',
61
- ],
62
- chatTemplateKwargs: 'object',
63
- chatTemplateArgs: 'object',
64
- supportsThinkingTokenBudget: 'boolean',
65
- supportsStrictMode: 'boolean',
66
- cacheControlFormat: ['anthropic'],
67
- supportsLongCacheRetention: 'boolean',
68
- },
69
- 'openai-responses': {
70
- supportsDeveloperRole: 'boolean',
71
- supportsStrictMode: 'boolean',
72
- supportsLongCacheRetention: 'boolean',
73
- },
74
- 'anthropic-messages': {
75
- supportsEagerToolInputStreaming: 'boolean',
76
- supportsLongCacheRetention: 'boolean',
77
- supportsCacheControlOnTools: 'boolean',
78
- supportsTemperature: 'boolean',
79
- forceAdaptiveThinking: 'boolean',
80
- allowEmptySignature: 'boolean',
81
- supportsStrictTools: 'boolean',
82
- },
38
+ let compatTable: Record<string, Record<string, CompatValue>> = {}
39
+
40
+ /** 安装服务端下发的字段表(card 挂载/刷新目录时调用)。 */
41
+ export function installCompatFields(table: Record<string, Record<string, CompatValue>>): void {
42
+ compatTable = table
83
43
  }
84
44
 
85
45
  /** 某协议的全部合法 compat 键(与服务端 compatFieldsOf 一致)。 */
86
46
  export function compatFieldsOf(api: string): readonly string[] {
87
- return Object.keys(COMPAT_FIELDS[api] ?? {})
47
+ return Object.keys(compatTable[api] ?? {})
88
48
  }
89
49
 
90
50
  /** 某协议某字段的取值约束(与服务端 compatFieldSpec 一致)。 */
91
51
  export function compatFieldSpec(api: string, field: string): CompatValue | undefined {
92
- return COMPAT_FIELDS[api]?.[field]
52
+ return compatTable[api]?.[field]
93
53
  }
94
54
 
95
55
  /** api 未设置时的渲染回退组(最常见的协议;保存仍由后端按实际协议校验)。 */
@@ -84,6 +84,64 @@ export function TextField(props: TextFieldProps): ReactElement {
84
84
  )
85
85
  }
86
86
 
87
+ export interface IntegerFieldProps {
88
+ id: string
89
+ label: string
90
+ hint?: string
91
+ invalidLabel: string
92
+ value: unknown
93
+ /** 放弃/保存后外部重置草稿时自增,驱动本地文本重新播种。 */
94
+ epoch: number
95
+ disabled?: boolean
96
+ wide?: boolean
97
+ /** 合法整数回调整数;空输入与非法输入回调 undefined。 */
98
+ onEdit(value: unknown): void
99
+ }
100
+
101
+ /**
102
+ * 整数字段:compat 的数值型开关(如 vllmPriority,官方 z.number().step(1))。
103
+ * 本地保留文本态(与 JsonField 同款 epoch 播种),仅在解析为整数时回写草稿——
104
+ * 小数/非数字显示错误且不提交,与服务端 checkValue 的整数语义一致
105
+ * (后端仍做最终校验)。
106
+ */
107
+ export function IntegerField(props: IntegerFieldProps): ReactElement {
108
+ const [text, setText] = useState(() => (props.value === undefined ? '' : String(props.value)))
109
+ // biome-ignore lint/correctness/useExhaustiveDependencies: 仅在 epoch 递增(外部重置语义)时同步文本,避免编辑中被 value 回写打断
110
+ useEffect(() => {
111
+ setText(props.value === undefined ? '' : String(props.value))
112
+ }, [props.epoch])
113
+ const trimmed = text.trim()
114
+ const parsed = trimmed === '' ? undefined : Number(trimmed)
115
+ const ok = trimmed === '' || (parsed !== undefined && Number.isInteger(parsed))
116
+ return (
117
+ <div className={`lpc-field${props.wide === true ? ' lpc-wide' : ''}`}>
118
+ <div className="lpc-head">
119
+ <label className="lpc-label" htmlFor={props.id}>
120
+ {props.label}
121
+ </label>
122
+ </div>
123
+ <input
124
+ id={props.id}
125
+ className={`lpc-input${ok ? '' : ' lpc-inputInvalid'}`}
126
+ type="text"
127
+ inputMode="numeric"
128
+ aria-invalid={ok ? undefined : true}
129
+ value={text}
130
+ disabled={props.disabled === true}
131
+ onChange={(event) => {
132
+ const next = event.target.value
133
+ setText(next)
134
+ const value = next.trim() === '' ? undefined : Number(next.trim())
135
+ props.onEdit(value !== undefined && Number.isInteger(value) ? value : undefined)
136
+ }}
137
+ />
138
+ <p className={ok ? 'lpc-hint' : 'lpc-invalid'}>
139
+ {ok ? (props.hint ?? '') : props.invalidLabel}
140
+ </p>
141
+ </div>
142
+ )
143
+ }
144
+
87
145
  export interface SelectFieldProps {
88
146
  id: string
89
147
  label: string
@@ -7,7 +7,7 @@ import { commonEn, commonZh, mergeDict } from '@dsh-plus/shared/client'
7
7
 
8
8
  export const NS = 'dsh-plus-llm-pi'
9
9
 
10
- const ownZh: Record<string, string> = {
10
+ const ownZh = {
11
11
  title: 'LLM 路由(llm-pi)',
12
12
  description: '自定义 LLM 路由:协议、compat、模型目录与 models.dev 目录兜底。',
13
13
  enabled: '启用插件',
@@ -62,6 +62,7 @@ const ownZh: Record<string, string> = {
62
62
  retryPolicy: '重试策略(JSON)',
63
63
  retryPolicyHint: 'dsh-llm RetryPolicy 形状;非法 JSON 不会提交。',
64
64
  invalidJson: 'JSON 格式错误(该字段不会提交)。',
65
+ invalidInteger: '必须是整数(该字段不会提交)。',
65
66
  compatGroup: 'Compat 覆盖',
66
67
  compatApiHint: 'api 未设置时暂按 openai-completions 字段组渲染;保存时后端按实际协议校验。',
67
68
  compatUnset: '未设置',
@@ -86,9 +87,9 @@ const ownZh: Record<string, string> = {
86
87
  catalogFailed: '目录加载失败。',
87
88
  saveOk: '已保存。',
88
89
  saveFailed: '保存失败:',
89
- }
90
+ } as const
90
91
 
91
- const ownEn: Record<string, string> = {
92
+ const ownEn = {
92
93
  title: 'LLM routes (llm-pi)',
93
94
  description: 'Custom LLM routes: protocol, compat, model catalog and models.dev fallback.',
94
95
  enabled: 'Enable plugin',
@@ -144,6 +145,7 @@ const ownEn: Record<string, string> = {
144
145
  retryPolicy: 'Retry policy (JSON)',
145
146
  retryPolicyHint: 'dsh-llm RetryPolicy shape; invalid JSON is not submitted.',
146
147
  invalidJson: 'Invalid JSON (this field will not be submitted).',
148
+ invalidInteger: 'Must be an integer (this field will not be submitted).',
147
149
  compatGroup: 'Compat overrides',
148
150
  compatApiHint:
149
151
  'When api is unset, fields render per openai-completions; the backend validates per the effective protocol.',
@@ -169,7 +171,13 @@ const ownEn: Record<string, string> = {
169
171
  catalogFailed: 'Failed to load catalog.',
170
172
  saveOk: 'Saved.',
171
173
  saveFailed: 'Save failed: ',
172
- }
174
+ } as const
173
175
 
174
- export const zh: Record<string, string> = mergeDict(commonZh, ownZh)
175
- export const en: Record<string, string> = mergeDict(commonEn, ownEn)
176
+ export type DictKey = keyof typeof commonZh | keyof typeof ownZh
177
+
178
+ /** 卡片/面板共用的翻译函数类型(slot 注入的 bind 结果按此消费)。 */
179
+ export type Translate = (key: DictKey) => string
180
+
181
+ // 标注为 Record<DictKey, string>:en 缺任一键即编译期报错(中英强制对齐)。
182
+ export const zh: Record<DictKey, string> = mergeDict(commonZh, ownZh)
183
+ export const en: Record<DictKey, string> = mergeDict(commonEn, ownEn)
@@ -8,7 +8,8 @@
8
8
  import type { ReactElement } from 'react'
9
9
 
10
10
  import { COMPAT_FALLBACK_API, compatFieldSpec, compatFieldsOf } from '../constants.ts'
11
- import { CollapseSection, JsonField, SelectField } from '../fields.tsx'
11
+ import { CollapseSection, IntegerField, JsonField, SelectField } from '../fields.tsx'
12
+ import type { Translate } from '../i18n.ts'
12
13
 
13
14
  /** api 变更后裁剪 compat:只保留新渲染组的字段,避免保存时被后端拒绝。 */
14
15
  export function pruneCompatForApi(
@@ -31,7 +32,7 @@ export interface CompatEditorProps {
31
32
  epoch: number
32
33
  disabled?: boolean
33
34
  wide?: boolean
34
- t(key: string): string
35
+ t: Translate
35
36
  onEdit(next: Record<string, unknown>): void
36
37
  }
37
38
 
@@ -89,6 +90,21 @@ export function CompatEditor(props: CompatEditorProps): ReactElement {
89
90
  />
90
91
  )
91
92
  }
93
+ if (spec === 'integer' || spec === 'number') {
94
+ return (
95
+ <IntegerField
96
+ key={field}
97
+ id={`${props.idPrefix}-${field}`}
98
+ label={field}
99
+ value={props.compat[field]}
100
+ epoch={props.epoch}
101
+ invalidLabel={props.t('invalidInteger')}
102
+ disabled={props.disabled === true}
103
+ onEdit={(value) => setField(field, value)}
104
+ />
105
+ )
106
+ }
107
+ // 余下只有枚举(readonly string[]):boolean/object/number 已在上方分支消化
92
108
  return (
93
109
  <SelectField
94
110
  key={field}
@@ -11,11 +11,12 @@ import { fetchCatalog, type WireModelsDevStatus } from '../api.ts'
11
11
  import { MODALITIES, THINKING_LEVELS } from '../constants.ts'
12
12
  import { emptyModelDraft, type ModelDraft, type ReasoningDraft } from '../draft.ts'
13
13
  import { TextField } from '../fields.tsx'
14
+ import type { Translate } from '../i18n.ts'
14
15
  import { CompatEditor } from './compat.tsx'
15
16
 
16
17
  type CatalogSource = 'builtin' | 'models-dev'
17
18
 
18
- function catalogNote(status: WireModelsDevStatus | undefined, t: (key: string) => string): string {
19
+ function catalogNote(status: WireModelsDevStatus | undefined, t: Translate): string {
19
20
  if (status === undefined) return ''
20
21
  if (status.error !== null) return `${t('modelsDevError')}${status.error}`
21
22
  return `${t('modelsDevStatusLine')}:${status.providers} 个 provider,快照 ${status.fetchedAt ?? '-'}`
@@ -29,7 +30,7 @@ export interface ModelsTableProps {
29
30
  models: ModelDraft[]
30
31
  epoch: number
31
32
  disabled?: boolean
32
- t(key: string): string
33
+ t: Translate
33
34
  onModels(next: ModelDraft[]): void
34
35
  }
35
36
 
@@ -158,7 +159,7 @@ export interface ModelRowProps {
158
159
  listId: string
159
160
  epoch: number
160
161
  disabled?: boolean
161
- t(key: string): string
162
+ t: Translate
162
163
  onPatch(patch: Partial<ModelDraft>): void
163
164
  onRemove(): void
164
165
  }
@@ -268,7 +269,7 @@ export interface ReasoningEditorProps {
268
269
  idPrefix: string
269
270
  value: ReasoningDraft
270
271
  disabled?: boolean
271
- t(key: string): string
272
+ t: Translate
272
273
  onEdit(next: ReasoningDraft): void
273
274
  }
274
275
 
@@ -17,13 +17,14 @@ import {
17
17
  } from '../constants.ts'
18
18
  import type { ProviderDraft } from '../draft.ts'
19
19
  import { SelectField, TextField } from '../fields.tsx'
20
+ import type { Translate } from '../i18n.ts'
20
21
  import { pruneCompatForApi } from './compat.tsx'
21
22
 
22
23
  export interface ProviderScalarFieldsProps {
23
24
  id: string
24
25
  draft: ProviderDraft
25
26
  disabled?: boolean
26
- t(key: string): string
27
+ t: Translate
27
28
  onPatch(patch: Partial<ProviderDraft>): void
28
29
  }
29
30
 
@@ -10,6 +10,7 @@ import { type ReactElement, useState } from 'react'
10
10
 
11
11
  import type { ProviderDraft } from '../draft.ts'
12
12
  import { CollapseSection, JsonField, KeyValueEditor } from '../fields.tsx'
13
+ import type { Translate } from '../i18n.ts'
13
14
  import { CompatEditor } from './compat.tsx'
14
15
  import { ModelsTable } from './models.tsx'
15
16
  import { ProviderScalarFields, ProviderSelectFields } from './provider-fields.tsx'
@@ -18,7 +19,7 @@ export interface ProvidersSectionProps {
18
19
  providers: Record<string, ProviderDraft>
19
20
  epoch: number
20
21
  disabled?: boolean
21
- t(key: string): string
22
+ t: Translate
22
23
  onAddRoute(key: string): void
23
24
  onRemoveRoute(route: string): void
24
25
  onPatchProvider(route: string, patch: Partial<ProviderDraft>): void
@@ -86,7 +87,7 @@ export interface ProviderSectionProps {
86
87
  draft: ProviderDraft
87
88
  epoch: number
88
89
  disabled?: boolean
89
- t(key: string): string
90
+ t: Translate
90
91
  onRemove(): void
91
92
  onPatch(patch: Partial<ProviderDraft>): void
92
93
  }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * compat 门控表的**自动继承**:从官方 `dsh-llm-pi-ai` 安装副本现场推导,
3
+ * 取代早期的手抄镜像表(手抄遗漏即静默误拒官方可配字段,见 ADR/文档的
4
+ * 0.1.5-rc.1 教训)。
5
+ *
6
+ * 官方不导出该表(包根仅 7 个符号、`src/` 不随 npm 发布),但有两条稳定可用的
7
+ * 机器可读来源,本模块同时消费:
8
+ *
9
+ * 1. **运行期 bundle**(`lib/index.js`,即真正执行门控的那份代码):
10
+ * `const COMPAT_GATES = { "openai-completions": COMPLETIONS_COMPAT_GATE, ... }`
11
+ * ——给出「协议 → 门控」映射(anthropic 在 bundle 内联,解析器同时支持
12
+ * 命名常量与内联对象两种形态)。门控表取自这里而非 d.ts,因为它是**事实源**:
13
+ * 运行期拒绝哪些字段由它决定。
14
+ * 2. **导出的 Config schema**(schemastery 标准 schema):`providers.*.compat`
15
+ * 节点逐字段给出取值约束(boolean/number.step(1)/enum/对象),据此推导
16
+ * 取值校验,不再手写 VALUE_SPECS。
17
+ *
18
+ * 推导失败(官方改布局/改打包形态)时回退到 FALLBACK_TABLE(最后已知快照,
19
+ * 随本文件版本冻结)并给出诊断——绝不因推导失败弄挂插件启动。
20
+ * @module llm-pi/compat-gates
21
+ */
22
+
23
+ /** 字段可配性:offer = 官方允许写;withhold = 官方为厂商内置、写时拒绝。 */
24
+ export type CompatDisposition = 'offer' | 'withhold'
25
+
26
+ /** 取值约束(从官方 schema 节点推导;'integer' = number 且 step=1)。 */
27
+ export type CompatValue = 'boolean' | 'integer' | 'number' | 'object' | readonly string[]
28
+
29
+ /** 推导结果:协议 → (字段 → 分型) + 字段 → 取值约束。 */
30
+ export interface CompatTable {
31
+ readonly gates: Readonly<Record<string, Readonly<Record<string, CompatDisposition>>>>
32
+ readonly specs: Readonly<Record<string, CompatValue>>
33
+ /** 数据来源(诊断/状态行):official = 现场推导成功。 */
34
+ readonly source: 'official' | 'fallback'
35
+ /** source=fallback 时的原因(供日志)。 */
36
+ readonly problem?: string
37
+ }
38
+
39
+ /** 官方 Config schema 的最小结构面(schemastery 节点;只读我们消费的部分)。 */
40
+ export interface SchemaNode {
41
+ type?: string
42
+ dict?: Record<string, SchemaNode>
43
+ list?: readonly SchemaNode[]
44
+ inner?: SchemaNode
45
+ meta?: { step?: number }
46
+ value?: unknown
47
+ }
48
+
49
+ /** 官方插件模块表面(推导所需的两个输入)。 */
50
+ export interface OfficialAdapterSurface {
51
+ /** `lib/index.js` 的绝对路径(读文本解析 COMPAT_GATES)。 */
52
+ bundlePath: string
53
+ /** 导入的模块命名空间(取 Config 导出)。 */
54
+ module: Record<string, unknown>
55
+ }
56
+
57
+ /** 从 `{` 起做括号配对,返回内部文本;未配对返回 undefined。 */
58
+ function balancedBody(text: string, openIndex: number): string | undefined {
59
+ const start = text.indexOf('{', openIndex)
60
+ if (start < 0) return undefined
61
+ let depth = 0
62
+ for (let i = start; i < text.length; i += 1) {
63
+ const ch = text[i]
64
+ if (ch === '{') depth += 1
65
+ else if (ch === '}') {
66
+ depth -= 1
67
+ if (depth === 0) return text.slice(start + 1, i)
68
+ }
69
+ }
70
+ return undefined
71
+ }
72
+
73
+ /** 解析 `field: "offer" | "withhold"` 键值对。 */
74
+ function parseGateFields(body: string): Record<string, CompatDisposition> {
75
+ const out: Record<string, CompatDisposition> = {}
76
+ for (const m of body.matchAll(/(\w+):\s*"(offer|withhold)"/g)) {
77
+ out[m[1] as string] = m[2] as CompatDisposition
78
+ }
79
+ return out
80
+ }
81
+
82
+ /** 取 `const NAME = { ... }` 的块体(兼容 `let`/`var` 与省略分号)。 */
83
+ function namedBlock(bundle: string, name: string): string | undefined {
84
+ const re = new RegExp(`(?:const|let|var)\\s+${name}\\s*=\\s*`)
85
+ const m = re.exec(bundle)
86
+ return m === null ? undefined : balancedBody(bundle, m.index + m[0].length)
87
+ }
88
+
89
+ /** 解析顶层 `"api": IDENT` 与 `"api": { ... }` 两种条目。 */
90
+ function splitGateEntries(body: string): Map<string, string | Record<string, CompatDisposition>> {
91
+ const out = new Map<string, string | Record<string, CompatDisposition>>()
92
+ const re = /"([^"]+)"\s*:\s*/g
93
+ let m: RegExpExecArray | null = re.exec(body)
94
+ while (m !== null) {
95
+ const api = m[1] as string
96
+ const rest = body.slice(m.index + m[0].length)
97
+ if (rest.startsWith('{')) {
98
+ const inner = balancedBody(rest, 0)
99
+ if (inner !== undefined) out.set(api, parseGateFields(inner))
100
+ } else {
101
+ const ident = /^[A-Za-z_$][\w$]*/.exec(rest)
102
+ if (ident !== null) out.set(api, ident[0])
103
+ }
104
+ m = re.exec(body)
105
+ }
106
+ return out
107
+ }
108
+
109
+ /**
110
+ * 从运行期 bundle 推导「协议 → 门控」:解析 COMPAT_GATES 的每条目,
111
+ * 命名常量回查同名 `const` 块,内联对象直接取字段。
112
+ */
113
+ export function deriveGates(
114
+ bundle: string,
115
+ ): Record<string, Record<string, CompatDisposition>> | undefined {
116
+ const gatesBody = namedBlock(bundle, 'COMPAT_GATES')
117
+ if (gatesBody === undefined) return undefined
118
+ const out: Record<string, Record<string, CompatDisposition>> = {}
119
+ for (const [api, ref] of splitGateEntries(gatesBody)) {
120
+ if (typeof ref !== 'string') {
121
+ out[api] = ref
122
+ continue
123
+ }
124
+ const block = namedBlock(bundle, ref)
125
+ if (block === undefined) continue
126
+ const fields = parseGateFields(block)
127
+ if (Object.keys(fields).length > 0) out[api] = fields
128
+ }
129
+ return Object.keys(out).length > 0 ? out : undefined
130
+ }
131
+
132
+ /** 单个 schema 节点 → 取值约束;无法归类的返回 undefined(跳过该字段)。 */
133
+ function specOfNode(node: SchemaNode | undefined): CompatValue | undefined {
134
+ if (node === undefined) return undefined
135
+ if (node.type === 'boolean') return 'boolean'
136
+ if (node.type === 'number') return node.meta?.step === 1 ? 'integer' : 'number'
137
+ if (node.type === 'dict') return 'object'
138
+ if (node.type === 'object') return 'object'
139
+ if (node.type === 'union' && Array.isArray(node.list) && node.list.length > 0) {
140
+ const values: string[] = []
141
+ for (const member of node.list) {
142
+ if (member?.type === 'const' && typeof member.value === 'string') values.push(member.value)
143
+ else return undefined // 含非字符串成员的联合(如模板 $var 对象)不入枚举
144
+ }
145
+ return values
146
+ }
147
+ return undefined
148
+ }
149
+
150
+ /** 从官方 Config schema 推导字段取值约束(`providers.*.compat` 节点)。 */
151
+ export function deriveSpecs(module: Record<string, unknown>): Record<string, CompatValue> {
152
+ const config = module['Config'] as SchemaNode | undefined
153
+ const compat = config?.dict?.['providers']?.inner?.dict?.['compat']
154
+ const fields = compat?.dict
155
+ if (fields === undefined) return {}
156
+ const out: Record<string, CompatValue> = {}
157
+ for (const [field, node] of Object.entries(fields)) {
158
+ const spec = specOfNode(node)
159
+ if (spec !== undefined) out[field] = spec
160
+ }
161
+ return out
162
+ }
163
+
164
+ /** 官方门控表不可解析时的最后已知快照(0.1.5-rc.2 实测值)。 */
165
+ export const FALLBACK_TABLE: CompatTable = {
166
+ source: 'fallback',
167
+ gates: {
168
+ 'openai-completions': {
169
+ supportsStore: 'offer',
170
+ supportsDeveloperRole: 'offer',
171
+ supportsReasoningEffort: 'offer',
172
+ supportsUsageInStreaming: 'offer',
173
+ supportsFinishReason: 'offer',
174
+ maxTokensField: 'offer',
175
+ requiresToolResultName: 'offer',
176
+ requiresAssistantAfterToolResult: 'offer',
177
+ requiresThinkingAsText: 'offer',
178
+ requiresReasoningContentOnAssistantMessages: 'offer',
179
+ thinkingFormat: 'offer',
180
+ chatTemplateKwargs: 'offer',
181
+ chatTemplateArgs: 'offer',
182
+ supportsThinkingTokenBudget: 'offer',
183
+ thinkingTokenBudgetField: 'offer',
184
+ vllmPriority: 'offer',
185
+ supportsStrictMode: 'offer',
186
+ cacheControlFormat: 'offer',
187
+ supportsLongCacheRetention: 'offer',
188
+ openRouterRouting: 'withhold',
189
+ vercelGatewayRouting: 'withhold',
190
+ zaiToolStream: 'withhold',
191
+ supportsOpenAIGrammarTools: 'withhold',
192
+ sendSessionAffinityHeaders: 'withhold',
193
+ deferredToolsMode: 'withhold',
194
+ sessionAffinityFormat: 'withhold',
195
+ },
196
+ 'openai-responses': {
197
+ supportsDeveloperRole: 'offer',
198
+ supportsMaxOutputTokens: 'offer',
199
+ supportsStrictMode: 'offer',
200
+ supportsLongCacheRetention: 'offer',
201
+ sessionAffinityFormat: 'withhold',
202
+ supportsOpenAIGrammarTools: 'withhold',
203
+ supportsAdditionalTools: 'withhold',
204
+ supportsToolSearch: 'withhold',
205
+ supportsExplicitPromptCacheMode: 'withhold',
206
+ },
207
+ 'anthropic-messages': {
208
+ supportsEagerToolInputStreaming: 'offer',
209
+ supportsLongCacheRetention: 'offer',
210
+ supportsCacheControlOnTools: 'offer',
211
+ supportsTemperature: 'offer',
212
+ forceAdaptiveThinking: 'offer',
213
+ allowEmptySignature: 'offer',
214
+ supportsStrictTools: 'offer',
215
+ sendSessionAffinityHeaders: 'withhold',
216
+ supportsToolReferences: 'withhold',
217
+ supportsMidConvoEffort: 'withhold',
218
+ allowedFallbackModels: 'withhold',
219
+ },
220
+ },
221
+ specs: {
222
+ supportsStore: 'boolean',
223
+ supportsDeveloperRole: 'boolean',
224
+ supportsReasoningEffort: 'boolean',
225
+ supportsUsageInStreaming: 'boolean',
226
+ supportsFinishReason: 'boolean',
227
+ maxTokensField: ['max_completion_tokens', 'max_tokens'],
228
+ requiresToolResultName: 'boolean',
229
+ requiresAssistantAfterToolResult: 'boolean',
230
+ requiresThinkingAsText: 'boolean',
231
+ requiresReasoningContentOnAssistantMessages: 'boolean',
232
+ thinkingFormat: [
233
+ 'openai',
234
+ 'deepseek',
235
+ 'openrouter',
236
+ 'together',
237
+ 'baseten',
238
+ 'zai',
239
+ 'qwen',
240
+ 'chat-template',
241
+ 'qwen-chat-template',
242
+ 'string-thinking',
243
+ 'ant-ling',
244
+ ],
245
+ chatTemplateKwargs: 'object',
246
+ chatTemplateArgs: 'object',
247
+ supportsThinkingTokenBudget: 'boolean',
248
+ thinkingTokenBudgetField: [
249
+ 'thinking_token_budget',
250
+ 'thinking_budget',
251
+ 'thinking_budget_tokens',
252
+ ],
253
+ vllmPriority: 'integer',
254
+ supportsMaxOutputTokens: 'boolean',
255
+ supportsStrictMode: 'boolean',
256
+ cacheControlFormat: ['anthropic'],
257
+ supportsLongCacheRetention: 'boolean',
258
+ supportsEagerToolInputStreaming: 'boolean',
259
+ supportsCacheControlOnTools: 'boolean',
260
+ supportsTemperature: 'boolean',
261
+ forceAdaptiveThinking: 'boolean',
262
+ allowEmptySignature: 'boolean',
263
+ supportsStrictTools: 'boolean',
264
+ },
265
+ }