@qjp88995/provider-client 0.0.0-stage → 0.1.1
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.
- package/dist/center-fetch.d.ts +29 -0
- package/dist/center-fetch.d.ts.map +1 -0
- package/dist/center-fetch.js +45 -0
- package/dist/center-fetch.js.map +1 -0
- package/dist/direct-resolver.d.ts +29 -0
- package/dist/direct-resolver.d.ts.map +1 -0
- package/dist/direct-resolver.js +47 -0
- package/dist/direct-resolver.js.map +1 -0
- package/dist/errors.d.ts +38 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +57 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/model-handle.d.ts +92 -0
- package/dist/model-handle.d.ts.map +1 -0
- package/dist/model-handle.js +72 -0
- package/dist/model-handle.js.map +1 -0
- package/dist/provider-client.d.ts +40 -0
- package/dist/provider-client.d.ts.map +1 -0
- package/dist/provider-client.js +96 -0
- package/dist/provider-client.js.map +1 -0
- package/dist/to-error.d.ts +12 -0
- package/dist/to-error.d.ts.map +1 -0
- package/dist/to-error.js +33 -0
- package/dist/to-error.js.map +1 -0
- package/dist/types.d.ts +78 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +75 -0
- package/dist/types.js.map +1 -0
- package/dist/vendors/alibaba.adapter.d.ts +47 -0
- package/dist/vendors/alibaba.adapter.d.ts.map +1 -0
- package/dist/vendors/alibaba.adapter.js +79 -0
- package/dist/vendors/alibaba.adapter.js.map +1 -0
- package/dist/vendors/ark-responses-shim.d.ts +25 -0
- package/dist/vendors/ark-responses-shim.d.ts.map +1 -0
- package/dist/vendors/ark-responses-shim.js +60 -0
- package/dist/vendors/ark-responses-shim.js.map +1 -0
- package/dist/vendors/deepseek.adapter.d.ts +50 -0
- package/dist/vendors/deepseek.adapter.d.ts.map +1 -0
- package/dist/vendors/deepseek.adapter.js +123 -0
- package/dist/vendors/deepseek.adapter.js.map +1 -0
- package/dist/vendors/dots.adapter.d.ts +32 -0
- package/dist/vendors/dots.adapter.d.ts.map +1 -0
- package/dist/vendors/dots.adapter.js +53 -0
- package/dist/vendors/dots.adapter.js.map +1 -0
- package/dist/vendors/generic.adapter.d.ts +33 -0
- package/dist/vendors/generic.adapter.d.ts.map +1 -0
- package/dist/vendors/generic.adapter.js +82 -0
- package/dist/vendors/generic.adapter.js.map +1 -0
- package/dist/vendors/google-signature.d.ts +17 -0
- package/dist/vendors/google-signature.d.ts.map +1 -0
- package/dist/vendors/google-signature.js +69 -0
- package/dist/vendors/google-signature.js.map +1 -0
- package/dist/vendors/google.adapter.d.ts +41 -0
- package/dist/vendors/google.adapter.d.ts.map +1 -0
- package/dist/vendors/google.adapter.js +164 -0
- package/dist/vendors/google.adapter.js.map +1 -0
- package/dist/vendors/index.d.ts +11 -0
- package/dist/vendors/index.d.ts.map +1 -0
- package/dist/vendors/index.js +39 -0
- package/dist/vendors/index.js.map +1 -0
- package/dist/vendors/minimax.adapter.d.ts +98 -0
- package/dist/vendors/minimax.adapter.d.ts.map +1 -0
- package/dist/vendors/minimax.adapter.js +163 -0
- package/dist/vendors/minimax.adapter.js.map +1 -0
- package/dist/vendors/moonshotai.adapter.d.ts +31 -0
- package/dist/vendors/moonshotai.adapter.d.ts.map +1 -0
- package/dist/vendors/moonshotai.adapter.js +57 -0
- package/dist/vendors/moonshotai.adapter.js.map +1 -0
- package/dist/vendors/openai-responses.d.ts +49 -0
- package/dist/vendors/openai-responses.d.ts.map +1 -0
- package/dist/vendors/openai-responses.js +64 -0
- package/dist/vendors/openai-responses.js.map +1 -0
- package/dist/vendors/openai.adapter.d.ts +27 -0
- package/dist/vendors/openai.adapter.d.ts.map +1 -0
- package/dist/vendors/openai.adapter.js +196 -0
- package/dist/vendors/openai.adapter.js.map +1 -0
- package/dist/vendors/responses-reasoning-text-shim.d.ts +50 -0
- package/dist/vendors/responses-reasoning-text-shim.d.ts.map +1 -0
- package/dist/vendors/responses-reasoning-text-shim.js +170 -0
- package/dist/vendors/responses-reasoning-text-shim.js.map +1 -0
- package/dist/vendors/vendor-adapter.d.ts +214 -0
- package/dist/vendors/vendor-adapter.d.ts.map +1 -0
- package/dist/vendors/vendor-adapter.js +150 -0
- package/dist/vendors/vendor-adapter.js.map +1 -0
- package/dist/vendors/volcengine.adapter.d.ts +74 -0
- package/dist/vendors/volcengine.adapter.d.ts.map +1 -0
- package/dist/vendors/volcengine.adapter.js +139 -0
- package/dist/vendors/volcengine.adapter.js.map +1 -0
- package/dist/vendors/zhipu.adapter.d.ts +81 -0
- package/dist/vendors/zhipu.adapter.d.ts.map +1 -0
- package/dist/vendors/zhipu.adapter.js +133 -0
- package/dist/vendors/zhipu.adapter.js.map +1 -0
- package/package.json +53 -3
- package/src/center-fetch.ts +69 -0
- package/src/direct-resolver.ts +90 -0
- package/src/errors.ts +88 -0
- package/src/index.ts +54 -0
- package/src/model-handle.ts +197 -0
- package/src/provider-client.ts +136 -0
- package/src/to-error.ts +39 -0
- package/src/types.ts +126 -0
- package/src/vendors/alibaba.adapter.ts +101 -0
- package/src/vendors/ark-responses-shim.ts +60 -0
- package/src/vendors/deepseek.adapter.ts +155 -0
- package/src/vendors/dots.adapter.ts +69 -0
- package/src/vendors/generic.adapter.ts +103 -0
- package/src/vendors/google-signature.ts +87 -0
- package/src/vendors/google.adapter.ts +197 -0
- package/src/vendors/index.ts +56 -0
- package/src/vendors/minimax.adapter.ts +193 -0
- package/src/vendors/moonshotai.adapter.ts +84 -0
- package/src/vendors/openai-responses.ts +82 -0
- package/src/vendors/openai.adapter.ts +235 -0
- package/src/vendors/responses-reasoning-text-shim.ts +174 -0
- package/src/vendors/vendor-adapter.ts +294 -0
- package/src/vendors/volcengine.adapter.ts +166 -0
- package/src/vendors/zhipu.adapter.ts +169 -0
- package/README.md +0 -3
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import type { LanguageModel } from 'ai';
|
|
2
|
+
|
|
3
|
+
import { ProviderClientError } from './errors.js';
|
|
4
|
+
import {
|
|
5
|
+
type CatalogModel,
|
|
6
|
+
type CatalogVendor,
|
|
7
|
+
type LlmCapability,
|
|
8
|
+
type LlmProtocol,
|
|
9
|
+
type ModelPricing,
|
|
10
|
+
REASONING_EFFORTS,
|
|
11
|
+
type ReasoningEffort,
|
|
12
|
+
} from './types.js';
|
|
13
|
+
import {
|
|
14
|
+
findVendorAdapter,
|
|
15
|
+
type LlmVendorAdapter,
|
|
16
|
+
type ProviderOptions,
|
|
17
|
+
type SamplingParams,
|
|
18
|
+
type VendorFetch,
|
|
19
|
+
type VendorFileStore,
|
|
20
|
+
} from './vendors/index.js';
|
|
21
|
+
|
|
22
|
+
/** 指向一个模型:厂商 slug + 厂商侧的模型名。 */
|
|
23
|
+
export interface ModelRef {
|
|
24
|
+
vendor: string;
|
|
25
|
+
modelId: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ResolveOptions {
|
|
29
|
+
/**
|
|
30
|
+
* 推理档位(`REASONING_EFFORTS`)。省略或不认识的值 = **什么都不发**(不是关闭);非推理模型也什么都不发
|
|
31
|
+
* ——给不会推理的模型发关闭指令有 400 风险。是 `string` 不是 `ReasoningEffort`:调用方的配置常是裸字符串,
|
|
32
|
+
* 不认识时不替它猜档位。
|
|
33
|
+
*/
|
|
34
|
+
reasoningEffort?: string;
|
|
35
|
+
/** 会话 id。中心据此把同一会话固定在同一把 key 上(前缀缓存、厂商侧文件与状态都绑账号)。直连模式忽略。 */
|
|
36
|
+
affinity?: string;
|
|
37
|
+
/**
|
|
38
|
+
* 钉住某把 key(上传文件时 `fileStore.upload` 回的 `credentialRef`)。那把 key 不可用时请求报
|
|
39
|
+
* `pinned_credential_unavailable`,不切换——调用方重新上传后不带 pin 重试。直连模式忽略。
|
|
40
|
+
*/
|
|
41
|
+
pin?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 上传结果;`credentialRef` 是中心模式下文件落在哪把 key 的账号上(直连为 null),之后引用它的请求要 pin 这把。 */
|
|
45
|
+
export interface UploadedFile {
|
|
46
|
+
fileId: string;
|
|
47
|
+
expiresAt: Date | null;
|
|
48
|
+
credentialRef: string | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ModelFileStore {
|
|
52
|
+
/** `FilePart.data.reference` 的命名空间 key(跟承载包走,不跟厂商走)。 */
|
|
53
|
+
referenceKey: string;
|
|
54
|
+
upload(
|
|
55
|
+
input: Parameters<VendorFileStore['upload']>[0]
|
|
56
|
+
): Promise<UploadedFile>;
|
|
57
|
+
delete(fileId: string): Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 一轮要的全部模型材料,一次解析出来、同源。 */
|
|
61
|
+
export interface ModelHandle {
|
|
62
|
+
ref: ModelRef;
|
|
63
|
+
/** `厂商名 / 模型显示名`。 */
|
|
64
|
+
label: string;
|
|
65
|
+
model: LanguageModel;
|
|
66
|
+
/** 实际选中的线协议(按模型声明的顺序取第一个「有端点且适配器已实现」的)。 */
|
|
67
|
+
protocol: LlmProtocol;
|
|
68
|
+
/** 推理档位已翻译成这家的参数;可以是 undefined(见 `ResolveOptions.reasoningEffort`)。 */
|
|
69
|
+
providerOptions: ProviderOptions | undefined;
|
|
70
|
+
/** 调用方给的档位,原样。 */
|
|
71
|
+
reasoningEffort: string | undefined;
|
|
72
|
+
limits: { maxContextTokens: number | null; maxOutputTokens: number | null };
|
|
73
|
+
capabilities: LlmCapability[];
|
|
74
|
+
/** 单价(每百万 token)。给接入方自己算用量。 */
|
|
75
|
+
pricing: ModelPricing | null;
|
|
76
|
+
/**
|
|
77
|
+
* 工具调用 `providerMetadata` 里要随 part 落库、重放时放回 `providerOptions` 的命名空间(google 的思考签名)。
|
|
78
|
+
* 别家为空:它们的 metadata 原样回放会改变请求形状。
|
|
79
|
+
*/
|
|
80
|
+
replayedMetadataKeys: readonly string[];
|
|
81
|
+
/** 厂商 Files API;没有为 null(调用方退到公网 URL / base64)。 */
|
|
82
|
+
fileStore: ModelFileStore | null;
|
|
83
|
+
/** 剥掉这家不接受的采样参数(Kimi 禁 temperature / top_p,AI SDK 不替我们挡)。不抛,调用方按 `dropped` 记 warn。 */
|
|
84
|
+
filterSampling(params: SamplingParams): {
|
|
85
|
+
accepted: SamplingParams;
|
|
86
|
+
dropped: (keyof SamplingParams)[];
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface ModelResolver {
|
|
91
|
+
/** 解析出一轮要用的句柄。厂商 / 模型不在目录里、协议对不上 → `ProviderClientError`。 */
|
|
92
|
+
resolve(ref: ModelRef, options?: ResolveOptions): Promise<ModelHandle>;
|
|
93
|
+
/** 只要能力、不造句柄。模型不在目录里给空数组。 */
|
|
94
|
+
capabilitiesOf(ref: ModelRef): Promise<LlmCapability[]>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const EFFORTS = new Set<string>(REASONING_EFFORTS);
|
|
98
|
+
|
|
99
|
+
export function findModel(
|
|
100
|
+
vendors: readonly CatalogVendor[],
|
|
101
|
+
ref: ModelRef
|
|
102
|
+
): { vendor: CatalogVendor; model: CatalogModel } {
|
|
103
|
+
const vendor = vendors.find(v => v.slug === ref.vendor);
|
|
104
|
+
if (!vendor) {
|
|
105
|
+
throw new ProviderClientError(
|
|
106
|
+
'unknown_vendor',
|
|
107
|
+
`vendor "${ref.vendor}" is not in the catalog`,
|
|
108
|
+
null
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
const model = vendor.models.find(m => m.modelId === ref.modelId);
|
|
112
|
+
if (!model) {
|
|
113
|
+
throw new ProviderClientError(
|
|
114
|
+
'unknown_model',
|
|
115
|
+
`model "${ref.modelId}" is not in vendor "${ref.vendor}"`,
|
|
116
|
+
null
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return { vendor, model };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function adapterFor(vendor: CatalogVendor): LlmVendorAdapter {
|
|
123
|
+
const adapter = findVendorAdapter(vendor.kind);
|
|
124
|
+
if (!adapter) {
|
|
125
|
+
throw new ProviderClientError(
|
|
126
|
+
'unsupported_vendor_kind',
|
|
127
|
+
`this provider-client has no adapter for vendor kind "${vendor.kind}" (vendor "${vendor.slug}"); upgrade the package`,
|
|
128
|
+
null
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return adapter;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 两种模式共用:从目录条目、凭证与出站 fetch 造句柄。 */
|
|
135
|
+
export function buildHandle(input: {
|
|
136
|
+
ref: ModelRef;
|
|
137
|
+
vendor: CatalogVendor;
|
|
138
|
+
model: CatalogModel;
|
|
139
|
+
apiKey: string;
|
|
140
|
+
/**
|
|
141
|
+
* 造出站 fetch。中心模式下 `onCredential` 收到这次实际用的 key;上传文件时每次单独造一个,
|
|
142
|
+
* 并发的请求互不串(文件落在哪把 key 上要拿准,之后引用它要 pin 这把)。
|
|
143
|
+
*/
|
|
144
|
+
fetchFor: (onCredential?: (ref: string) => void) => VendorFetch | undefined;
|
|
145
|
+
reasoningEffort: string | undefined;
|
|
146
|
+
}): ModelHandle {
|
|
147
|
+
const { ref, vendor, model, reasoningEffort } = input;
|
|
148
|
+
const adapter = adapterFor(vendor);
|
|
149
|
+
const { protocol, baseUrl } = adapter.chooseEndpoint(model, vendor.endpoints);
|
|
150
|
+
const createInput = {
|
|
151
|
+
protocol,
|
|
152
|
+
baseUrl,
|
|
153
|
+
apiKey: input.apiKey,
|
|
154
|
+
modelId: model.modelId,
|
|
155
|
+
fetch: input.fetchFor(),
|
|
156
|
+
};
|
|
157
|
+
const store = adapter.createFileStore(createInput);
|
|
158
|
+
return {
|
|
159
|
+
ref,
|
|
160
|
+
label: `${vendor.name} / ${model.displayName}`,
|
|
161
|
+
model: adapter.createModel(createInput),
|
|
162
|
+
protocol,
|
|
163
|
+
providerOptions:
|
|
164
|
+
model.capabilities.includes('reasoning') &&
|
|
165
|
+
reasoningEffort !== undefined &&
|
|
166
|
+
EFFORTS.has(reasoningEffort)
|
|
167
|
+
? adapter.reasoning(
|
|
168
|
+
model.modelId,
|
|
169
|
+
reasoningEffort as ReasoningEffort,
|
|
170
|
+
protocol
|
|
171
|
+
)
|
|
172
|
+
: undefined,
|
|
173
|
+
reasoningEffort,
|
|
174
|
+
limits: {
|
|
175
|
+
maxContextTokens: model.maxContextTokens,
|
|
176
|
+
maxOutputTokens: model.maxOutputTokens,
|
|
177
|
+
},
|
|
178
|
+
capabilities: [...model.capabilities],
|
|
179
|
+
pricing: model.pricing,
|
|
180
|
+
replayedMetadataKeys: adapter.replayedMetadataKeys,
|
|
181
|
+
fileStore: store && {
|
|
182
|
+
referenceKey: store.referenceKey,
|
|
183
|
+
async upload(file) {
|
|
184
|
+
let credentialRef: string | null = null;
|
|
185
|
+
const once = adapter.createFileStore({
|
|
186
|
+
...createInput,
|
|
187
|
+
fetch: input.fetchFor(ref => (credentialRef = ref)),
|
|
188
|
+
});
|
|
189
|
+
if (!once) throw new Error('file store disappeared');
|
|
190
|
+
const r = await once.upload(file);
|
|
191
|
+
return { ...r, credentialRef };
|
|
192
|
+
},
|
|
193
|
+
delete: fileId => store.delete(fileId),
|
|
194
|
+
},
|
|
195
|
+
filterSampling: params => adapter.filterSampling(params),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createCenterFetch } from './center-fetch.js';
|
|
2
|
+
import { errorFromResponse, ProviderClientError } from './errors.js';
|
|
3
|
+
import {
|
|
4
|
+
buildHandle,
|
|
5
|
+
findModel,
|
|
6
|
+
type ModelHandle,
|
|
7
|
+
type ModelRef,
|
|
8
|
+
type ModelResolver,
|
|
9
|
+
type ResolveOptions,
|
|
10
|
+
} from './model-handle.js';
|
|
11
|
+
import { type CatalogResponse, PC_HEADERS } from './types.js';
|
|
12
|
+
import type { VendorFetch } from './vendors/index.js';
|
|
13
|
+
|
|
14
|
+
export interface ProviderClientOptions {
|
|
15
|
+
/** 中心的根地址,如 `https://provider.example.com`。 */
|
|
16
|
+
centerUrl: string;
|
|
17
|
+
/** 接入方 API key(`pc_…`)。 */
|
|
18
|
+
apiKey: string;
|
|
19
|
+
/**
|
|
20
|
+
* 目录在这段时间内直接用缓存、不去中心复核(毫秒,默认 60 秒)。过期后带 `If-None-Match` 复核,
|
|
21
|
+
* 没变只回 304。中心改了目录最多这么久才被看到。
|
|
22
|
+
*/
|
|
23
|
+
catalogTtlMs?: number;
|
|
24
|
+
/** 目录请求的超时(毫秒,默认 10 秒)。透传请求不受它管(流式回答可以很长)。 */
|
|
25
|
+
timeoutMs?: number;
|
|
26
|
+
/** 发往中心用的 fetch(测试替身、自定义代理);默认全局 fetch。 */
|
|
27
|
+
fetch?: VendorFetch;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 中心模式:模型目录从中心拉,所有厂商请求经中心透传(中心换上真 key、坏了自动换下一把)。
|
|
32
|
+
* 本进程从头到尾不碰厂商 key。
|
|
33
|
+
*/
|
|
34
|
+
export class ProviderClient implements ModelResolver {
|
|
35
|
+
private readonly center: string;
|
|
36
|
+
private readonly send: VendorFetch;
|
|
37
|
+
private readonly ttlMs: number;
|
|
38
|
+
private readonly timeoutMs: number;
|
|
39
|
+
private cached: {
|
|
40
|
+
etag: string | null;
|
|
41
|
+
body: CatalogResponse;
|
|
42
|
+
checkedAt: number;
|
|
43
|
+
} | null = null;
|
|
44
|
+
private inflight: Promise<CatalogResponse> | null = null;
|
|
45
|
+
|
|
46
|
+
constructor(private readonly options: ProviderClientOptions) {
|
|
47
|
+
this.center = options.centerUrl.replace(/\/+$/, '');
|
|
48
|
+
this.send = options.fetch ?? globalThis.fetch;
|
|
49
|
+
this.ttlMs = options.catalogTtlMs ?? 60_000;
|
|
50
|
+
this.timeoutMs = options.timeoutMs ?? 10_000;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 这个接入方能用的厂商与模型(按白名单过滤过)。 */
|
|
54
|
+
catalog(): Promise<CatalogResponse> {
|
|
55
|
+
if (this.cached && Date.now() - this.cached.checkedAt < this.ttlMs) {
|
|
56
|
+
return Promise.resolve(this.cached.body);
|
|
57
|
+
}
|
|
58
|
+
this.inflight ??= this.fetchCatalog().finally(() => {
|
|
59
|
+
this.inflight = null;
|
|
60
|
+
});
|
|
61
|
+
return this.inflight;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 丢掉目录缓存(中心改了配置、想立刻看到时)。 */
|
|
65
|
+
invalidateCatalog(): void {
|
|
66
|
+
this.cached = null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async resolve(
|
|
70
|
+
ref: ModelRef,
|
|
71
|
+
options: ResolveOptions = {}
|
|
72
|
+
): Promise<ModelHandle> {
|
|
73
|
+
const { vendor, model } = findModel((await this.catalog()).vendors, ref);
|
|
74
|
+
const baseUrls = vendor.endpoints.map(e => e.baseUrl);
|
|
75
|
+
return buildHandle({
|
|
76
|
+
ref,
|
|
77
|
+
vendor,
|
|
78
|
+
model,
|
|
79
|
+
// 厂商 SDK 要求有 key 才肯造句柄;它放进的鉴权头会被中心模式 fetch 剥掉。
|
|
80
|
+
apiKey: 'via-provider-center',
|
|
81
|
+
fetchFor: onCredential =>
|
|
82
|
+
createCenterFetch({
|
|
83
|
+
centerUrl: this.center,
|
|
84
|
+
apiKey: this.options.apiKey,
|
|
85
|
+
vendor: vendor.slug,
|
|
86
|
+
baseUrls,
|
|
87
|
+
affinity: options.affinity,
|
|
88
|
+
pin: options.pin,
|
|
89
|
+
onCredential,
|
|
90
|
+
fetch: this.send,
|
|
91
|
+
}),
|
|
92
|
+
reasoningEffort: options.reasoningEffort,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async capabilitiesOf(ref: ModelRef) {
|
|
97
|
+
const vendor = (await this.catalog()).vendors.find(
|
|
98
|
+
v => v.slug === ref.vendor
|
|
99
|
+
);
|
|
100
|
+
const model = vendor?.models.find(m => m.modelId === ref.modelId);
|
|
101
|
+
return model ? [...model.capabilities] : [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private async fetchCatalog(): Promise<CatalogResponse> {
|
|
105
|
+
const headers: Record<string, string> = {
|
|
106
|
+
[PC_HEADERS.key]: this.options.apiKey,
|
|
107
|
+
};
|
|
108
|
+
if (this.cached?.etag) headers['if-none-match'] = this.cached.etag;
|
|
109
|
+
let res: Response;
|
|
110
|
+
try {
|
|
111
|
+
res = await this.send(`${this.center}/v1/catalog`, {
|
|
112
|
+
headers,
|
|
113
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
114
|
+
});
|
|
115
|
+
} catch (err) {
|
|
116
|
+
throw new ProviderClientError(
|
|
117
|
+
'network',
|
|
118
|
+
`provider center unreachable: ${err instanceof Error ? err.message : String(err)}`,
|
|
119
|
+
null,
|
|
120
|
+
{ cause: err }
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (res.status === 304 && this.cached) {
|
|
124
|
+
this.cached.checkedAt = Date.now();
|
|
125
|
+
return this.cached.body;
|
|
126
|
+
}
|
|
127
|
+
if (!res.ok) throw await errorFromResponse(res);
|
|
128
|
+
const body = (await res.json()) as CatalogResponse;
|
|
129
|
+
this.cached = {
|
|
130
|
+
etag: res.headers.get('etag'),
|
|
131
|
+
body,
|
|
132
|
+
checkedAt: Date.now(),
|
|
133
|
+
};
|
|
134
|
+
return body;
|
|
135
|
+
}
|
|
136
|
+
}
|
package/src/to-error.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { APICallError, RetryError } from 'ai';
|
|
2
|
+
|
|
3
|
+
import { parseCenterError, ProviderClientError } from './errors.js';
|
|
4
|
+
import { PC_HEADERS } from './types.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 把一次 AI SDK 调用的错误里**中心自己报的**那部分认出来(`no_credential`、`vendor_not_allowed`、
|
|
8
|
+
* `pinned_credential_unavailable`、`upstream_error` ……),变成 `ProviderClientError`;别的错误(厂商原样回的
|
|
9
|
+
* 4xx / 5xx、网络、取消)给 null,调用方照旧处理。
|
|
10
|
+
*
|
|
11
|
+
* 判据:响应体是中心的 `{ code, message }`,**且没有 `x-pc-credential` 响应头**——中心只在把厂商的响应
|
|
12
|
+
* 原样回传时才带这个头,厂商自己的错误体碰巧长得像也不会被误认。AI SDK 重试耗尽时包在 `RetryError` 里,
|
|
13
|
+
* 看它的最后一次。
|
|
14
|
+
*/
|
|
15
|
+
export function toProviderClientError(
|
|
16
|
+
error: unknown
|
|
17
|
+
): ProviderClientError | null {
|
|
18
|
+
const last = RetryError.isInstance(error) ? error.lastError : error;
|
|
19
|
+
if (!APICallError.isInstance(last)) return null;
|
|
20
|
+
const headers = last.responseHeaders ?? {};
|
|
21
|
+
const fromVendor = Object.keys(headers).some(
|
|
22
|
+
k => k.toLowerCase() === PC_HEADERS.credential
|
|
23
|
+
);
|
|
24
|
+
if (fromVendor || !last.responseBody) return null;
|
|
25
|
+
let body: unknown;
|
|
26
|
+
try {
|
|
27
|
+
body = JSON.parse(last.responseBody);
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const known = parseCenterError(body);
|
|
32
|
+
if (!known) return null;
|
|
33
|
+
return new ProviderClientError(
|
|
34
|
+
known.code,
|
|
35
|
+
known.message,
|
|
36
|
+
last.statusCode ?? null,
|
|
37
|
+
{ cause: error }
|
|
38
|
+
);
|
|
39
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 与中心的契约。`@pc/shared` 是私有包发不出去,这里是它的手抄本——`contract.spec.ts` 逐项对照两边,
|
|
3
|
+
* 服务端改了契约这里当场红。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** 线协议(wire 上的请求 / 响应格式)。 */
|
|
7
|
+
export const LLM_PROTOCOLS = [
|
|
8
|
+
'openai-chat-completions',
|
|
9
|
+
'openai-responses',
|
|
10
|
+
'anthropic-messages',
|
|
11
|
+
'google-generate-content',
|
|
12
|
+
'google-interactions',
|
|
13
|
+
] as const;
|
|
14
|
+
export type LlmProtocol = (typeof LLM_PROTOCOLS)[number];
|
|
15
|
+
|
|
16
|
+
/** 模型能力(目录元数据:发不发图片 / 开不开推理由调用方据此决定)。 */
|
|
17
|
+
export const LLM_CAPABILITIES = ['vision', 'video', 'reasoning'] as const;
|
|
18
|
+
export type LlmCapability = (typeof LLM_CAPABILITIES)[number];
|
|
19
|
+
|
|
20
|
+
/** 厂商类型。一种类型对应一个适配器(`VENDOR_ADAPTERS`)。 */
|
|
21
|
+
export const VENDOR_KINDS = [
|
|
22
|
+
'openai',
|
|
23
|
+
'generic',
|
|
24
|
+
'deepseek',
|
|
25
|
+
'moonshotai',
|
|
26
|
+
'alibaba',
|
|
27
|
+
'zhipu',
|
|
28
|
+
'volcengine',
|
|
29
|
+
'minimax',
|
|
30
|
+
'dots',
|
|
31
|
+
'google',
|
|
32
|
+
] as const;
|
|
33
|
+
export type VendorKind = (typeof VENDOR_KINDS)[number];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 推理档位——**我们自己的抽象**,不是任何一家的原生枚举,各家适配器的 `reasoning()` 把它翻译成自己的形状:
|
|
37
|
+
*
|
|
38
|
+
* - `off` 发**真正的关闭指令**(deepseek 默认开着推理,「不发」≠ 关闭);只有厂商根本没有关闭开关时
|
|
39
|
+
* (如 kimi-k3)才不发。
|
|
40
|
+
* - `minimal` 只有 openai 有,其余家降档 `low`。
|
|
41
|
+
* - `max` 是 deepseek 与 kimi-k3 的原生最高档,openai 映射到 `xhigh`。
|
|
42
|
+
* - 只有开关没有档位的家(alibaba、kimi-k2.x),非 `off` 一律塌缩成「开」。
|
|
43
|
+
*/
|
|
44
|
+
export const REASONING_EFFORTS = [
|
|
45
|
+
'off',
|
|
46
|
+
'minimal',
|
|
47
|
+
'low',
|
|
48
|
+
'medium',
|
|
49
|
+
'high',
|
|
50
|
+
'max',
|
|
51
|
+
] as const;
|
|
52
|
+
export type ReasoningEffort = (typeof REASONING_EFFORTS)[number];
|
|
53
|
+
|
|
54
|
+
/** 每百万 token 的单价,十进制字符串(这是钱,别过 `Number`)。给接入方自己算用量用。 */
|
|
55
|
+
export interface ModelPricing {
|
|
56
|
+
currency: string;
|
|
57
|
+
input: string;
|
|
58
|
+
output: string;
|
|
59
|
+
cacheRead?: string | null;
|
|
60
|
+
cacheWrite?: string | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `GET /v1/catalog`:这个接入方能用的厂商、端点、模型。不含任何凭证信息。 */
|
|
64
|
+
export interface CatalogResponse {
|
|
65
|
+
vendors: CatalogVendor[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface CatalogVendor {
|
|
69
|
+
/** 透传路由段,也是 `ModelRef.vendor`。 */
|
|
70
|
+
slug: string;
|
|
71
|
+
name: string;
|
|
72
|
+
kind: VendorKind;
|
|
73
|
+
endpoints: CatalogEndpoint[];
|
|
74
|
+
models: CatalogModel[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface CatalogEndpoint {
|
|
78
|
+
protocol: LlmProtocol;
|
|
79
|
+
baseUrl: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface CatalogModel {
|
|
83
|
+
/** 厂商侧的模型名(请求体里的 `model`)。 */
|
|
84
|
+
modelId: string;
|
|
85
|
+
displayName: string;
|
|
86
|
+
capabilities: LlmCapability[];
|
|
87
|
+
/** 有序:解析时按这个顺序取第一个「有端点且适配器已实现」的协议。 */
|
|
88
|
+
protocols: LlmProtocol[];
|
|
89
|
+
maxContextTokens: number | null;
|
|
90
|
+
maxOutputTokens: number | null;
|
|
91
|
+
pricing: ModelPricing | null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 中心的错误码(响应体 `{ code, message }`)。 */
|
|
95
|
+
export const CENTER_ERROR_CODES = [
|
|
96
|
+
'unauthorized',
|
|
97
|
+
'forbidden',
|
|
98
|
+
'tenant_disabled',
|
|
99
|
+
'not_found',
|
|
100
|
+
'invalid_request',
|
|
101
|
+
'conflict',
|
|
102
|
+
'unprocessable',
|
|
103
|
+
'upstream_error',
|
|
104
|
+
'admin_unavailable',
|
|
105
|
+
'vendor_not_allowed',
|
|
106
|
+
'no_credential',
|
|
107
|
+
'pinned_credential_unavailable',
|
|
108
|
+
] as const;
|
|
109
|
+
export type CenterErrorCode = (typeof CENTER_ERROR_CODES)[number];
|
|
110
|
+
|
|
111
|
+
/** 中心的请求头 / 响应头。 */
|
|
112
|
+
export const PC_HEADERS = {
|
|
113
|
+
/** 接入方 API key(`pc_…`)。 */
|
|
114
|
+
key: 'x-pc-key',
|
|
115
|
+
/** 会话粘性键:同一个键在健康的 key 里固定落到同一把。 */
|
|
116
|
+
affinity: 'x-pc-affinity',
|
|
117
|
+
/** 钉住某把 key(值是 `credential` 响应头给过的 ref),不可用时报错不切换。 */
|
|
118
|
+
pin: 'x-pc-pin',
|
|
119
|
+
/** 响应头:这次实际用的 key(不透明 ref)。 */
|
|
120
|
+
credential: 'x-pc-credential',
|
|
121
|
+
/** 响应头:这次换过几次 key。 */
|
|
122
|
+
switches: 'x-pc-switches',
|
|
123
|
+
} as const;
|
|
124
|
+
|
|
125
|
+
/** 透传路由前缀:`<中心>/v1/proxy/<厂商 slug>/<厂商 URL 的完整 pathname>`。 */
|
|
126
|
+
export const PROXY_ROUTE_PREFIX = '/v1/proxy';
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { createAlibaba } from '@ai-sdk/alibaba';
|
|
2
|
+
import type { LanguageModel } from 'ai';
|
|
3
|
+
|
|
4
|
+
import type { LlmProtocol } from '../types.js';
|
|
5
|
+
import {
|
|
6
|
+
COMPATIBLE_RESPONSES_OPTIONS,
|
|
7
|
+
createResponsesModel,
|
|
8
|
+
} from './openai-responses.js';
|
|
9
|
+
import { withReasoningTextResponsesShim } from './responses-reasoning-text-shim.js';
|
|
10
|
+
import {
|
|
11
|
+
type CreateModelInput,
|
|
12
|
+
LlmVendorAdapter,
|
|
13
|
+
type ProviderOptions,
|
|
14
|
+
type ReasoningEffort,
|
|
15
|
+
unsupportedProtocol,
|
|
16
|
+
} from './vendor-adapter.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 阿里云百炼(DashScope)。两条协议**同一个根** `/compatible-mode/v1`,SDK 各自拼
|
|
20
|
+
* `/chat/completions` 与 `/responses`(文档 `referenes/alibaba-model.md`;百炼另有
|
|
21
|
+
* 业务空间专属域名 `https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com`,公共域名
|
|
22
|
+
* 「现有域名仍可正常使用」,预填公共域名——专属域名要填业务空间 ID,运营自己换)。
|
|
23
|
+
*
|
|
24
|
+
* **responses 面的判据来自 2026-08-27 对 `qwen3.8-flash` 的探针**,文档与实况有一处
|
|
25
|
+
* 出入必须以实况为准:文档示例里流式推理事件写的是 OpenAI 原生的
|
|
26
|
+
* `response.reasoning_summary_text.delta`,**真机回的是 `response.reasoning_text.delta`
|
|
27
|
+
* / `.done`(带 `content_index`)**——与 DeepSeek、智谱同一形状,所以同样挂
|
|
28
|
+
* `withReasoningTextResponsesShim`;而 `output_item.added/done`、`response.completed`
|
|
29
|
+
* 与非流式响应体里的 reasoning item 用的**是**原生 `summary[{summary_text}]`,垫片
|
|
30
|
+
* 对已有非空 `summary` 的 item 不动,两种形状在同一条流里各自落到位。SSE 还多发
|
|
31
|
+
* `id:` 与 `:HTTP_STATUS/200` 两行,SDK 的解析器按规范忽略。
|
|
32
|
+
*/
|
|
33
|
+
export class AlibabaAdapter extends LlmVendorAdapter {
|
|
34
|
+
readonly kind = 'alibaba' as const;
|
|
35
|
+
|
|
36
|
+
override readonly supportedProtocols: readonly LlmProtocol[] = [
|
|
37
|
+
'openai-chat-completions',
|
|
38
|
+
'openai-responses',
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
createModel(input: CreateModelInput): LanguageModel {
|
|
42
|
+
switch (input.protocol) {
|
|
43
|
+
case 'openai-chat-completions': {
|
|
44
|
+
const { baseUrl, apiKey, modelId, fetch } = input;
|
|
45
|
+
// baseUrl 指 OpenAI 兼容端点(`/compatible-mode/v1`)。该包另有
|
|
46
|
+
// embedding/video 两条走 DashScope 原生端点的支线(`embeddingBaseURL`/
|
|
47
|
+
// `videoBaseURL`),本仓只用 languageModel,不配。
|
|
48
|
+
return createAlibaba({ baseURL: baseUrl, apiKey, fetch })(modelId);
|
|
49
|
+
}
|
|
50
|
+
case 'openai-responses':
|
|
51
|
+
return createResponsesModel(
|
|
52
|
+
input,
|
|
53
|
+
withReasoningTextResponsesShim(input.fetch)
|
|
54
|
+
);
|
|
55
|
+
default:
|
|
56
|
+
throw unsupportedProtocol(this, input.protocol);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* **chat-completions** 只有开关,没有档位:`alibabaLanguageModelChatOptions` 就
|
|
62
|
+
* `enableThinking`(布尔)+ `thinkingBudget`(token 数),没有 `reasoningEffort`。
|
|
63
|
+
* 非 off 的四个档位一律塌缩成"开"——**如实映射,不发明厂商没有的粒度**
|
|
64
|
+
* (thinking budget 是 token 数,不是档位,替用户拍一个数没有依据)。
|
|
65
|
+
*
|
|
66
|
+
* **responses** 只有 `reasoning.effort` 一个开关,厂商原生七档
|
|
67
|
+
* `none/minimal/low/medium/high/xhigh/max`(默认 `xhigh`;文档:`xhigh`/`max`
|
|
68
|
+
* 仅北京、新加坡地域)。我们的六档**逐个都有同名原生档**,一律直通、不映射:
|
|
69
|
+
* `off`→`none` 是真正的关闭指令(探针 `reasoning_tokens: 0`、不出 reasoning
|
|
70
|
+
* item),`minimal` 与 `max` 各自 200。文档还说 `enable_thinking` 将废弃、
|
|
71
|
+
* `reasoning.effort` 优先级更高,所以这个面上只发 effort。key 随承载包换成
|
|
72
|
+
* `openai`,并带 `COMPATIBLE_RESPONSES_OPTIONS`(不带则 SDK 按模型名判成非推理
|
|
73
|
+
* 模型、`reasoning` 整个不发——它默认 `xhigh`,「不发 ≠ 关闭」在这家也成立;
|
|
74
|
+
* `developer` 角色实测也收,但只保证 `system`)。代价同 DeepSeek:SDK 在
|
|
75
|
+
* `forceReasoning` 下剥掉 `temperature`/`top_p`(厂商本身收),记为已知限制。
|
|
76
|
+
*
|
|
77
|
+
* 请求侧回传:四种形状(原样 output / 只回 function_call / `item_reference` /
|
|
78
|
+
* `previous_response_id`)与 `store:false` 全部 200;它**会消费**回传的 reasoning
|
|
79
|
+
* item(带着回传比不带多出恰好上一轮 reasoning_tokens 的 input_tokens),SDK 默认
|
|
80
|
+
* 发什么就发什么。
|
|
81
|
+
*/
|
|
82
|
+
override reasoning(
|
|
83
|
+
_modelId: string,
|
|
84
|
+
effort: ReasoningEffort,
|
|
85
|
+
protocol: LlmProtocol
|
|
86
|
+
): ProviderOptions {
|
|
87
|
+
switch (protocol) {
|
|
88
|
+
case 'openai-chat-completions':
|
|
89
|
+
return { alibaba: { enableThinking: effort !== 'off' } };
|
|
90
|
+
case 'openai-responses':
|
|
91
|
+
return {
|
|
92
|
+
openai: {
|
|
93
|
+
reasoningEffort: effort === 'off' ? 'none' : effort,
|
|
94
|
+
...COMPATIBLE_RESPONSES_OPTIONS,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
default:
|
|
98
|
+
throw unsupportedProtocol(this, protocol);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ResponsesFetch } from './openai-responses.js';
|
|
2
|
+
import { withoutLength } from './responses-reasoning-text-shim.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 火山方舟(Ark)Responses 端点的 HTTP 层垫片——**只改响应、只改非流式**。
|
|
6
|
+
*
|
|
7
|
+
* 方舟的 Responses 面在推理形状上是 OpenAI 原生的(reasoning item 用
|
|
8
|
+
* `summary[{summary_text}]`、流式事件是 `response.reasoning_summary_text.delta`,
|
|
9
|
+
* 2026-09-07 对 `doubao-seed-2-1-pro` / `2-0-lite` 的探针逐字段核对过),所以
|
|
10
|
+
* **不**用 `withReasoningTextResponsesShim`。它与 OpenAI 唯一会让 SDK 炸的差异是:
|
|
11
|
+
* 非流式响应体里 `output_text` 内容块**没有 `annotations` 字段**,而
|
|
12
|
+
* `@ai-sdk/openai` 4.0.46 的非流式 zod schema 把它写成必填数组(dist:4107),
|
|
13
|
+
* 于是 `generateText` / `generateObject` 一律 `Invalid JSON response`——请求明明
|
|
14
|
+
* 200、钱也扣了,SDK 却报错。流式路径不受影响:SSE 事件的 schema 里这个字段是
|
|
15
|
+
* 可选的(真实 SDK 回放过,见 `volcengine.sdk-contract.spec.ts`),所以流式原样透传。
|
|
16
|
+
*
|
|
17
|
+
* 请求侧的两处差异(拒收 `reasoning.summary`、拒收 `item_reference`)不在这里
|
|
18
|
+
* 改:它们由 SDK 的 providerOptions 就能关掉(`reasoningSummary: null` /
|
|
19
|
+
* `store: false`),改请求体去猜 SDK 内部结构没有必要——见 `volcengine.adapter.ts`。
|
|
20
|
+
*/
|
|
21
|
+
export function withArkResponsesShim(
|
|
22
|
+
base?: ResponsesFetch
|
|
23
|
+
): NonNullable<ResponsesFetch> {
|
|
24
|
+
return async (url, init) => {
|
|
25
|
+
// 延迟到调用时再取全局 fetch:测试用 vi.stubGlobal 换掉的才是这一个。
|
|
26
|
+
const res = await (base ?? globalThis.fetch)(url, init);
|
|
27
|
+
if (!res.ok || !res.body) return res;
|
|
28
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
29
|
+
if (!contentType.includes('application/json')) return res;
|
|
30
|
+
const body: unknown = await res.json();
|
|
31
|
+
fillOutputTextAnnotations(body);
|
|
32
|
+
return new Response(JSON.stringify(body), {
|
|
33
|
+
status: res.status,
|
|
34
|
+
statusText: res.statusText,
|
|
35
|
+
headers: withoutLength(res.headers),
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type JsonObject = Record<string, unknown>;
|
|
41
|
+
|
|
42
|
+
function isObject(v: unknown): v is JsonObject {
|
|
43
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 给 response 对象里每个 `message.content[].output_text` 补上缺失的
|
|
48
|
+
* `annotations: []`。已有的不动;别的类型的 item / part 不碰。
|
|
49
|
+
*/
|
|
50
|
+
export function fillOutputTextAnnotations(body: unknown): void {
|
|
51
|
+
if (!isObject(body) || !Array.isArray(body.output)) return;
|
|
52
|
+
for (const item of body.output) {
|
|
53
|
+
if (!isObject(item) || item.type !== 'message') continue;
|
|
54
|
+
if (!Array.isArray(item.content)) continue;
|
|
55
|
+
for (const part of item.content) {
|
|
56
|
+
if (!isObject(part) || part.type !== 'output_text') continue;
|
|
57
|
+
if (part.annotations == null) part.annotations = [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|