@hmharness/evolution 0.14.23 → 0.14.25

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.
@@ -1,58 +1,46 @@
1
1
  import type { DpoPair } from './reward-model.ts';
2
- /** A fine-tunable model + how its jobs are priced. Prices change - the
3
- * caller renders the estimate, the catalog only carries what we verified. */
4
- export interface FineTuneModelInfo {
2
+ import type { ProviderConfig } from '@hmharness/kernel';
3
+ /** One provider's fine-tuning dialect. Everything provider-specific lives
4
+ * here; the transport and job flow above it are generic. */
5
+ export interface FineTuneBackend {
5
6
  id: string;
6
- dpo: boolean;
7
- note: string;
7
+ /** Chinese, human-facing (shown in `hmh auto-finetune` output). */
8
+ label: string;
9
+ /** Does this provider entry belong to this backend? */
10
+ matches(baseUrl: string): boolean;
11
+ /** Normalize a chat baseUrl into the fine-tuning API root. */
12
+ apiBase(baseUrl: string): string;
13
+ /** Fine-tune base candidates, the user's MAIN model first. */
14
+ modelLadder(mainModel: string): string[];
15
+ /** Serialize pairs into this provider's DPO JSONL. */
16
+ dpoJsonl(pairs: Array<Pick<DpoPair, 'prompt' | 'chosen' | 'rejected'>>): string;
17
+ /** Does this API error text mean "this model is not fine-tunable here"?
18
+ * Used by the zero-side-effect model probe. */
19
+ modelRejected(errorText: string): boolean;
8
20
  }
9
- /** Models verified as fine-tunable (guide + live probe 2026-09-25: bare
10
- * glm-4-air 404s "微调模型不存在", glm-4-flash passes model validation). */
11
- export declare const FINE_TUNE_CATALOG: FineTuneModelInfo[];
12
- export declare const DEFAULT_FINE_TUNE_MODEL = "glm-4-flash";
13
- /**
14
- * Model auto-selection (user direction 2026-09-25: novices must never pick
15
- * a fine-tune base). Candidates: the user's MAIN model first - if the API
16
- * rejects it (live probe 2026-09-25: glm-5.3 answers "微调功能未开放") the
17
- * ladder silently falls to the next documented fine-tunable base. The probe
18
- * POSTs {model, training_file:"file-probe-invalid"} - model validation runs
19
- * BEFORE file binding, so the request always fails without creating
20
- * anything, and the error text tells us whether the model is usable.
21
- */
22
- export declare function fineTuneModelLadder(mainModel: string): string[];
23
- /** Zero-side-effect model probe (the request can never create a job - the
24
- * training_file id is invalid on purpose). "model rejected" vs "reached
25
- * file validation" is distinguishable in the error text. */
26
- export declare function probeFineTuneModel(opts: {
21
+ /** Zhipu bigmodel.cn - verified live 2026-09-25 (models glm-5.3 rejected
22
+ * "微调功能未开放", glm-4-air "微调模型不存在", glm-4-flash/glm-4.5-air pass). */
23
+ export declare const ZHIPU_BACKEND: FineTuneBackend;
24
+ /** Registry - one entry per provider dialect, matched against the user's
25
+ * configured providers at runtime. Extend by adding a verified backend. */
26
+ export declare const FINE_TUNE_BACKENDS: FineTuneBackend[];
27
+ /** Kept for the existing tests/callers: the Zhipu row format. */
28
+ export declare const toDpoJsonl: (pairs: Array<Pick<DpoPair, "prompt" | "chosen" | "rejected">>) => string;
29
+ export interface ResolvedFineTune {
30
+ backend: FineTuneBackend;
31
+ providerName: string;
27
32
  apiKey: string;
28
33
  baseUrl: string;
34
+ /** the user's MAIN model on that provider (first ladder candidate) */
29
35
  model: string;
30
- fetchImpl?: typeof fetch;
31
- }): Promise<{
32
- usable: boolean;
33
- reason?: string;
34
- }>;
35
- /** Walk the ladder, return the first usable model plus why earlier ones
36
- * were skipped (surfaced as one Chinese line - the backend adapts, the
37
- * user reads, never chooses). */
38
- export declare function pickFineTuneModel(opts: {
39
- apiKey: string;
40
- baseUrl: string;
41
- mainModel: string;
42
- log?: (line: string) => void;
43
- fetchImpl?: typeof fetch;
44
- }): Promise<string>;
36
+ }
45
37
  /**
46
- * Fine-tuning does not live on the coding endpoint: config.json points the
47
- * chat route at /api/coding/paas/v4, but the fine_tuning/file APIs sit on
48
- * /api/paas/v4. Strip the coding segment so the user's existing glm provider
49
- * entry works unchanged (zero new configuration).
38
+ * Match the user's providers against the backend registry. The CHAT route's
39
+ * provider is tried first (fine-tune what you actually use), then any other
40
+ * configured provider. Returns null when nothing matches - the caller
41
+ * surfaces "not supported yet" as a status, never as an error.
50
42
  */
51
- export declare function normalizeFineTuneBase(baseUrl: string): string;
52
- /** Serialize pairs into the provider DPO JSONL row format (pure). Accepts
53
- * the slim {prompt,chosen,rejected} rows the CLI persists - provenance
54
- * fields never leave the machine. */
55
- export declare function toDpoJsonl(pairs: Array<Pick<DpoPair, 'prompt' | 'chosen' | 'rejected'>>): string;
43
+ export declare function resolveFineTuneBackend(providers: Record<string, ProviderConfig> | undefined, chatProviderName?: string): ResolvedFineTune | null;
56
44
  /** Rough token estimate for a cost preview before upload (chars/4 heuristic;
57
45
  * the upload response returns exact per-tokenizer stats which the UI shows
58
46
  * alongside - this is only the pre-flight number). */
@@ -69,6 +57,7 @@ export interface UploadedFile {
69
57
  }
70
58
  /** Upload a training/validation JSONL (free operation, verified live). */
71
59
  export declare function uploadFineTuneFile(opts: {
60
+ backend: FineTuneBackend;
72
61
  apiKey: string;
73
62
  baseUrl: string;
74
63
  filename: string;
@@ -87,6 +76,7 @@ export interface FineTuneJob {
87
76
  /** Create a fine-tuning job (PAID - callers must gate behind explicit
88
77
  * submit consent; uploads alone are free). */
89
78
  export declare function createFineTuneJob(opts: {
79
+ backend: FineTuneBackend;
90
80
  apiKey: string;
91
81
  baseUrl: string;
92
82
  model: string;
@@ -101,23 +91,56 @@ export declare function createFineTuneJob(opts: {
101
91
  fetchImpl?: typeof fetch;
102
92
  }): Promise<FineTuneJob>;
103
93
  export declare function listFineTuneJobs(opts: {
94
+ backend: FineTuneBackend;
104
95
  apiKey: string;
105
96
  baseUrl: string;
106
97
  limit?: number;
107
98
  fetchImpl?: typeof fetch;
108
99
  }): Promise<FineTuneJob[]>;
109
100
  export declare function retrieveFineTuneJob(opts: {
101
+ backend: FineTuneBackend;
110
102
  apiKey: string;
111
103
  baseUrl: string;
112
104
  jobId: string;
113
105
  fetchImpl?: typeof fetch;
114
106
  }): Promise<FineTuneJob>;
115
107
  export declare function cancelFineTuneJob(opts: {
108
+ backend: FineTuneBackend;
116
109
  apiKey: string;
117
110
  baseUrl: string;
118
111
  jobId: string;
119
112
  fetchImpl?: typeof fetch;
120
113
  }): Promise<FineTuneJob>;
114
+ /**
115
+ * Model auto-selection (user direction 2026-09-25: novices never pick a
116
+ * fine-tune base). Candidates: the user's MAIN model first - if the API
117
+ * rejects it the ladder silently falls to the next documented fine-tunable
118
+ * base. The probe POSTs {model, training_file:"file-probe-invalid"} -
119
+ * model validation runs BEFORE file binding, so the request always fails
120
+ * without creating anything, and the error text tells us whether the model
121
+ * is usable.
122
+ */
123
+ export declare function probeFineTuneModel(opts: {
124
+ backend: FineTuneBackend;
125
+ apiKey: string;
126
+ baseUrl: string;
127
+ model: string;
128
+ fetchImpl?: typeof fetch;
129
+ }): Promise<{
130
+ usable: boolean;
131
+ reason?: string;
132
+ }>;
133
+ /** Walk the ladder, return the first usable model plus why earlier ones
134
+ * were skipped (surfaced as one Chinese line - the backend adapts, the
135
+ * user reads, never chooses). */
136
+ export declare function pickFineTuneModel(opts: {
137
+ backend: FineTuneBackend;
138
+ apiKey: string;
139
+ baseUrl: string;
140
+ mainModel: string;
141
+ log?: (line: string) => void;
142
+ fetchImpl?: typeof fetch;
143
+ }): Promise<string>;
121
144
  /** Append-only record of jobs this machine submitted (audit trail: what was
122
145
  * trained, on which file, when - survives provider console churn). */
123
146
  export declare function recordFineTuneJob(home: string, job: FineTuneJob, extra?: Record<string, unknown>): Promise<void>;
@@ -1,113 +1,97 @@
1
1
  /**
2
2
  * Cloud fine-tuning client (2026-09-25, closes the auto-finetune loop).
3
3
  *
4
- * Direction (user, 2026-09-24): users configure ONE api key, the backend
5
- * adapts everything else; local-GPU training was a developer experiment.
6
- * This module submits the accumulated DPO preference pairs to the provider's
7
- * fine-tuning API so "gets smarter as you use it" needs no local stack.
4
+ * Direction (user, 2026-09-24/25): users configure ONE api key for whatever
5
+ * provider they use, the backend adapts everything else. Fine-tuning is
6
+ * therefore a PROVIDER-AGNOSTIC capability: a registry of backends, each
7
+ * knowing one provider's endpoints, accepted data format and model rules.
8
+ * Zhipu is the first backend because it is the only one verified live
9
+ * (2026-09-25, real key, real uploads) - NOT because hmh targets Zhipu.
10
+ * A user on any other provider gets a clean "not supported yet" status,
11
+ * never an error, and everything else keeps working.
8
12
  *
9
- * Endpoint surface verified live 2026-09-25 against open.bigmodel.cn with a
10
- * real key (read-only probes + one 85-byte purpose=fine-tune upload):
11
- * POST /api/paas/v4/files multipart file + purpose=fine-tune
12
- * POST /api/paas/v4/fine_tuning/jobs {model, training_file, ...}
13
- * GET /api/paas/v4/fine_tuning/jobs list
14
- * GET /api/paas/v4/fine_tuning/jobs/{id} retrieve
15
- * POST /api/paas/v4/fine_tuning/jobs/{id}/cancel
16
- * GET /api/paas/v4/fine_tuning/jobs/{id}/events
17
- * Cross-checked field names against zhipuai SDK 2.1.5 source
18
- * (api_resource/fine_tuning/jobs/jobs.py). The DPO row format follows the
19
- * official guide: {"input":{messages,tools,parallel_tool_calls},
20
- * "preferred_output":[...],"non_preferred_output":[...]}.
13
+ * Zhipu endpoint surface (verified live 2026-09-25):
14
+ * POST {apiBase}/files multipart file + purpose=fine-tune
15
+ * POST {apiBase}/fine_tuning/jobs {model, training_file, ...}
16
+ * GET {apiBase}/fine_tuning/jobs list (+ /{id}, /{id}/cancel, /{id}/events)
17
+ * Cross-checked field names against zhipuai SDK 2.1.5 source. The DPO row
18
+ * format is the LIVE-verified one, which differs from Zhipu's own docs
19
+ * (see zhipuDpoJsonl).
21
20
  */
22
21
  import { appendFile, mkdir, readFile } from 'node:fs/promises';
23
22
  import { join } from 'node:path';
24
- /** Models verified as fine-tunable (guide + live probe 2026-09-25: bare
25
- * glm-4-air 404s "微调模型不存在", glm-4-flash passes model validation). */
26
- export const FINE_TUNE_CATALOG = [
27
- { id: 'glm-4-flash', dpo: true, note: '最便宜,LoRA 需开发者 Pro 权益' },
28
- { id: 'glm-4.5-air', dpo: false, note: '全参微调,对所有用户开放(SFT)' },
29
- { id: 'glm-4-air-250414', dpo: true, note: '全参微调' },
30
- { id: 'glm-4-air-x', dpo: true, note: '全参微调' },
31
- ];
32
- export const DEFAULT_FINE_TUNE_MODEL = 'glm-4-flash';
33
- /**
34
- * Model auto-selection (user direction 2026-09-25: novices must never pick
35
- * a fine-tune base). Candidates: the user's MAIN model first - if the API
36
- * rejects it (live probe 2026-09-25: glm-5.3 answers "微调功能未开放") the
37
- * ladder silently falls to the next documented fine-tunable base. The probe
38
- * POSTs {model, training_file:"file-probe-invalid"} - model validation runs
39
- * BEFORE file binding, so the request always fails without creating
40
- * anything, and the error text tells us whether the model is usable.
41
- */
42
- export function fineTuneModelLadder(mainModel) {
43
- const ladder = [mainModel, DEFAULT_FINE_TUNE_MODEL, 'glm-4.5-air'];
44
- return [...new Set(ladder.filter(Boolean))];
45
- }
46
- /** Zero-side-effect model probe (the request can never create a job - the
47
- * training_file id is invalid on purpose). "model rejected" vs "reached
48
- * file validation" is distinguishable in the error text. */
49
- export async function probeFineTuneModel(opts) {
50
- const url = normalizeFineTuneBase(opts.baseUrl) + '/fine_tuning/jobs';
51
- const res = await (opts.fetchImpl ?? fetch)(url, {
52
- method: 'POST',
53
- headers: { Authorization: `Bearer ${opts.apiKey}`, 'Content-Type': 'application/json' },
54
- body: JSON.stringify({ model: opts.model, training_file: 'file-probe-invalid' }),
55
- });
56
- const text = await res.text();
57
- if (res.ok)
58
- return { usable: true }; // unreachable in practice; harmless
59
- const rejected = /微调模型不存在|微调功能未开放|无权限|not found/i.test(text);
60
- return { usable: !rejected, reason: rejected ? text.slice(0, 160) : undefined };
61
- }
62
- /** Walk the ladder, return the first usable model plus why earlier ones
63
- * were skipped (surfaced as one Chinese line - the backend adapts, the
64
- * user reads, never chooses). */
65
- export async function pickFineTuneModel(opts) {
66
- for (const m of fineTuneModelLadder(opts.mainModel)) {
67
- const r = await probeFineTuneModel({ apiKey: opts.apiKey, baseUrl: opts.baseUrl, model: m, fetchImpl: opts.fetchImpl });
68
- if (r.usable)
69
- return m;
70
- opts.log?.(`微调候选 ${m} 不可用(${(r.reason ?? '').replace(/^.*"message":"([^"]*)".*$/, '$1').slice(0, 60) || 'API 拒绝'}),自动改试下一个`);
71
- }
72
- throw new Error('所有微调候选模型均不可用——请在智谱控制台确认微调权限(可能需联系客服开放),或用 --model= 指定其他可微调模型');
73
- }
74
- /**
75
- * Fine-tuning does not live on the coding endpoint: config.json points the
76
- * chat route at /api/coding/paas/v4, but the fine_tuning/file APIs sit on
77
- * /api/paas/v4. Strip the coding segment so the user's existing glm provider
78
- * entry works unchanged (zero new configuration).
79
- */
80
- export function normalizeFineTuneBase(baseUrl) {
81
- let u = baseUrl.trim().replace(/\/+$/, '');
82
- u = u.replace(/\/coding\/paas\/v4$/, '/paas/v4');
83
- if (!/\/api\/paas\/v4$/.test(u)) {
84
- const m = u.match(/^(https?:\/\/[^/]+)/);
85
- if (!m)
86
- throw new Error('baseUrl 需以 http(s):// 开头: ' + baseUrl);
87
- u = m[1] + '/api/paas/v4';
88
- }
89
- return u;
90
- }
91
- /** Serialize pairs into the provider DPO JSONL row format (pure). Accepts
92
- * the slim {prompt,chosen,rejected} rows the CLI persists - provenance
93
- * fields never leave the machine. */
94
- export function toDpoJsonl(pairs) {
23
+ const zhipuDpoJsonl = (pairs) => {
95
24
  const lines = [];
96
25
  for (const p of pairs) {
97
26
  if (!p.prompt.trim() || !p.chosen.trim() || !p.rejected.trim())
98
27
  continue; // quality gate mirrors rl-governance
99
- const row = {
100
- input: {
101
- messages: [{ role: 'user', content: p.prompt }],
102
- tools: [],
103
- parallel_tool_calls: false,
104
- },
28
+ // Live-verified 2026-09-25: the documented {"input":{"messages":...}}
29
+ // nesting is REJECTED by the upload validator ("缺少messages字段"); the
30
+ // accepted shape is top-level messages (MUST contain an assistant turn -
31
+ // "缺少assistant角色" otherwise) plus preferred_output/non_preferred_output.
32
+ // The assistant turn carries the chosen answer, so the row is a complete
33
+ // good trajectory and the preference pair trains the separation.
34
+ lines.push(JSON.stringify({
35
+ messages: [
36
+ { role: 'user', content: p.prompt },
37
+ { role: 'assistant', content: p.chosen },
38
+ ],
105
39
  preferred_output: [{ role: 'assistant', content: p.chosen }],
106
40
  non_preferred_output: [{ role: 'assistant', content: p.rejected }],
107
- };
108
- lines.push(JSON.stringify(row));
41
+ }));
109
42
  }
110
43
  return lines.join('\n') + (lines.length ? '\n' : '');
44
+ };
45
+ /** Zhipu bigmodel.cn - verified live 2026-09-25 (models glm-5.3 rejected
46
+ * "微调功能未开放", glm-4-air "微调模型不存在", glm-4-flash/glm-4.5-air pass). */
47
+ export const ZHIPU_BACKEND = {
48
+ id: 'zhipu',
49
+ label: '智谱(bigmodel.cn)',
50
+ matches: (baseUrl) => baseUrl.includes('bigmodel.cn'),
51
+ apiBase: (baseUrl) => {
52
+ let u = baseUrl.trim().replace(/\/+$/, '');
53
+ u = u.replace(/\/coding\/paas\/v4$/, '/paas/v4');
54
+ if (!/\/api\/paas\/v4$/.test(u)) {
55
+ const m = u.match(/^(https?:\/\/[^/]+)/);
56
+ if (!m)
57
+ throw new Error('baseUrl 需以 http(s):// 开头: ' + baseUrl);
58
+ u = m[1] + '/api/paas/v4';
59
+ }
60
+ return u;
61
+ },
62
+ modelLadder: (mainModel) => [...new Set([mainModel, 'glm-4-flash', 'glm-4.5-air'].filter(Boolean))],
63
+ dpoJsonl: zhipuDpoJsonl,
64
+ modelRejected: (text) => /微调模型不存在|微调功能未开放|无权限|not found/i.test(text),
65
+ };
66
+ /** Registry - one entry per provider dialect, matched against the user's
67
+ * configured providers at runtime. Extend by adding a verified backend. */
68
+ export const FINE_TUNE_BACKENDS = [ZHIPU_BACKEND];
69
+ /** Kept for the existing tests/callers: the Zhipu row format. */
70
+ export const toDpoJsonl = zhipuDpoJsonl;
71
+ /**
72
+ * Match the user's providers against the backend registry. The CHAT route's
73
+ * provider is tried first (fine-tune what you actually use), then any other
74
+ * configured provider. Returns null when nothing matches - the caller
75
+ * surfaces "not supported yet" as a status, never as an error.
76
+ */
77
+ export function resolveFineTuneBackend(providers, chatProviderName) {
78
+ if (!providers)
79
+ return null;
80
+ const ordered = [];
81
+ if (chatProviderName && providers[chatProviderName])
82
+ ordered.push([chatProviderName, providers[chatProviderName]]);
83
+ for (const [name, p] of Object.entries(providers)) {
84
+ if (!ordered.some(([n]) => n === name))
85
+ ordered.push([name, p]);
86
+ }
87
+ for (const [name, p] of ordered) {
88
+ if (!p.baseUrl || !p.apiKey)
89
+ continue;
90
+ const backend = FINE_TUNE_BACKENDS.find((b) => b.matches(p.baseUrl));
91
+ if (backend)
92
+ return { backend, providerName: name, apiKey: p.apiKey, baseUrl: p.baseUrl, model: p.model };
93
+ }
94
+ return null;
111
95
  }
112
96
  /** Rough token estimate for a cost preview before upload (chars/4 heuristic;
113
97
  * the upload response returns exact per-tokenizer stats which the UI shows
@@ -120,7 +104,7 @@ export function estimateTokens(jsonl) {
120
104
  export function estimateTrainingCost(tokens, epochs, pricePerKTokens) {
121
105
  return Math.round(((tokens / 1000) * epochs * pricePerKTokens) * 100) / 100;
122
106
  }
123
- async function zhipuFetch(url, apiKey, init, fetchImpl) {
107
+ async function apiFetch(url, apiKey, init, fetchImpl) {
124
108
  const res = await fetchImpl(url, {
125
109
  ...init,
126
110
  headers: { Authorization: `Bearer ${apiKey}`, ...init.headers },
@@ -141,11 +125,11 @@ async function zhipuFetch(url, apiKey, init, fetchImpl) {
141
125
  }
142
126
  /** Upload a training/validation JSONL (free operation, verified live). */
143
127
  export async function uploadFineTuneFile(opts) {
144
- const url = normalizeFineTuneBase(opts.baseUrl) + '/files';
128
+ const url = opts.backend.apiBase(opts.baseUrl) + '/files';
145
129
  const form = new FormData();
146
130
  form.append('purpose', 'fine-tune');
147
131
  form.append('file', new Blob([opts.jsonl], { type: 'application/jsonl' }), opts.filename);
148
- const body = await zhipuFetch(url, opts.apiKey, { method: 'POST', body: form }, opts.fetchImpl ?? fetch);
132
+ const body = await apiFetch(url, opts.apiKey, { method: 'POST', body: form }, opts.fetchImpl ?? fetch);
149
133
  const o = body;
150
134
  const tokens = Math.max(0, ...(o.text_stats ?? []).map((t) => t.tokens ?? 0));
151
135
  return { id: o.id, bytes: o.bytes, samples: o.samples ?? 0, tokens };
@@ -153,7 +137,7 @@ export async function uploadFineTuneFile(opts) {
153
137
  /** Create a fine-tuning job (PAID - callers must gate behind explicit
154
138
  * submit consent; uploads alone are free). */
155
139
  export async function createFineTuneJob(opts) {
156
- const url = normalizeFineTuneBase(opts.baseUrl) + '/fine_tuning/jobs';
140
+ const url = opts.backend.apiBase(opts.baseUrl) + '/fine_tuning/jobs';
157
141
  const payload = { model: opts.model, training_file: opts.trainingFile };
158
142
  if (opts.validationFile)
159
143
  payload.validation_file = opts.validationFile;
@@ -161,24 +145,59 @@ export async function createFineTuneJob(opts) {
161
145
  payload.suffix = opts.suffix;
162
146
  if (opts.hyperparameters)
163
147
  payload.hyperparameters = opts.hyperparameters;
164
- return (await zhipuFetch(url, opts.apiKey, {
148
+ return (await apiFetch(url, opts.apiKey, {
165
149
  method: 'POST',
166
150
  headers: { 'Content-Type': 'application/json' },
167
151
  body: JSON.stringify(payload),
168
152
  }, opts.fetchImpl ?? fetch));
169
153
  }
170
154
  export async function listFineTuneJobs(opts) {
171
- const url = normalizeFineTuneBase(opts.baseUrl) + `/fine_tuning/jobs?limit=${opts.limit ?? 10}`;
172
- const body = await zhipuFetch(url, opts.apiKey, { method: 'GET' }, opts.fetchImpl ?? fetch);
155
+ const url = opts.backend.apiBase(opts.baseUrl) + `/fine_tuning/jobs?limit=${opts.limit ?? 10}`;
156
+ const body = await apiFetch(url, opts.apiKey, { method: 'GET' }, opts.fetchImpl ?? fetch);
173
157
  return (body.data ?? []);
174
158
  }
175
159
  export async function retrieveFineTuneJob(opts) {
176
- const url = normalizeFineTuneBase(opts.baseUrl) + `/fine_tuning/jobs/${opts.jobId}`;
177
- return (await zhipuFetch(url, opts.apiKey, { method: 'GET' }, opts.fetchImpl ?? fetch));
160
+ const url = opts.backend.apiBase(opts.baseUrl) + `/fine_tuning/jobs/${opts.jobId}`;
161
+ return (await apiFetch(url, opts.apiKey, { method: 'GET' }, opts.fetchImpl ?? fetch));
178
162
  }
179
163
  export async function cancelFineTuneJob(opts) {
180
- const url = normalizeFineTuneBase(opts.baseUrl) + `/fine_tuning/jobs/${opts.jobId}/cancel`;
181
- return (await zhipuFetch(url, opts.apiKey, { method: 'POST' }, opts.fetchImpl ?? fetch));
164
+ const url = opts.backend.apiBase(opts.baseUrl) + `/fine_tuning/jobs/${opts.jobId}/cancel`;
165
+ return (await apiFetch(url, opts.apiKey, { method: 'POST' }, opts.fetchImpl ?? fetch));
166
+ }
167
+ /* ---------------- model auto-selection ---------------- */
168
+ /**
169
+ * Model auto-selection (user direction 2026-09-25: novices never pick a
170
+ * fine-tune base). Candidates: the user's MAIN model first - if the API
171
+ * rejects it the ladder silently falls to the next documented fine-tunable
172
+ * base. The probe POSTs {model, training_file:"file-probe-invalid"} -
173
+ * model validation runs BEFORE file binding, so the request always fails
174
+ * without creating anything, and the error text tells us whether the model
175
+ * is usable.
176
+ */
177
+ export async function probeFineTuneModel(opts) {
178
+ const url = opts.backend.apiBase(opts.baseUrl) + '/fine_tuning/jobs';
179
+ const res = await (opts.fetchImpl ?? fetch)(url, {
180
+ method: 'POST',
181
+ headers: { Authorization: `Bearer ${opts.apiKey}`, 'Content-Type': 'application/json' },
182
+ body: JSON.stringify({ model: opts.model, training_file: 'file-probe-invalid' }),
183
+ });
184
+ const text = await res.text();
185
+ if (res.ok)
186
+ return { usable: true }; // unreachable in practice; harmless
187
+ const rejected = opts.backend.modelRejected(text);
188
+ return { usable: !rejected, reason: rejected ? text.slice(0, 160) : undefined };
189
+ }
190
+ /** Walk the ladder, return the first usable model plus why earlier ones
191
+ * were skipped (surfaced as one Chinese line - the backend adapts, the
192
+ * user reads, never chooses). */
193
+ export async function pickFineTuneModel(opts) {
194
+ for (const m of opts.backend.modelLadder(opts.mainModel)) {
195
+ const r = await probeFineTuneModel({ backend: opts.backend, apiKey: opts.apiKey, baseUrl: opts.baseUrl, model: m, fetchImpl: opts.fetchImpl });
196
+ if (r.usable)
197
+ return m;
198
+ opts.log?.(`微调候选 ${m} 不可用(${(r.reason ?? '').replace(/^.*"message":"([^"]*)".*$/, '$1').slice(0, 60) || 'API 拒绝'}),自动改试下一个`);
199
+ }
200
+ throw new Error('所有微调候选模型均不可用——请在服务商控制台确认微调权限(可能需联系客服开放),或用 --model= 指定其他可微调模型');
182
201
  }
183
202
  /* ---------------- local job ledger ---------------- */
184
203
  /** Append-only record of jobs this machine submitted (audit trail: what was
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hmharness/evolution",
3
- "version": "0.14.23",
3
+ "version": "0.14.25",
4
4
  "description": "hmharness evolution subsystem: persistent memory, insight capture, skill library, and the bench that gives evolution its fitness signal. First-class kernel citizen, not a plugin.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",