Skip to content

Commit bbe988a

Browse files
committed
Fix Hermes discovery and Claude Code gateway setup
1 parent 748e7a2 commit bbe988a

11 files changed

Lines changed: 344 additions & 30 deletions

README.en.md

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -213,16 +213,34 @@ FreeNIMAPI answers Codex model-catalog requests with Codex-specific `ModelInfo`
213213

214214
`ANTHROPIC_BASE_URL` points to the server root, **without** `/v1`:
215215

216+
Do not choose an item on Claude Code's login screen: an arbitrary gateway is
217+
not configured through that menu. Stop that screen with `Ctrl+C`, then paste
218+
the entire block below into the **same terminal** that will launch `claude`.
219+
The block supplies the guided-mode loopback key even if you skipped the shared
220+
`export FREENIM_LOCAL_API_KEY` above:
221+
216222
```bash
223+
export FREENIM_LOCAL_API_KEY="${FREENIM_LOCAL_API_KEY:-freenim-local}"
224+
export FREENIM_MODEL="${FREENIM_MODEL:-nim-auto}"
217225
export ANTHROPIC_BASE_URL=http://127.0.0.1:3000
218226
export ANTHROPIC_AUTH_TOKEN="$FREENIM_LOCAL_API_KEY"
219-
export ANTHROPIC_MODEL=nim-auto
220-
export ANTHROPIC_DEFAULT_OPUS_MODEL=nim-auto
221-
export ANTHROPIC_DEFAULT_SONNET_MODEL=nim-auto
222-
export ANTHROPIC_DEFAULT_HAIKU_MODEL=nim-auto
223-
claude
227+
export ANTHROPIC_MODEL="$FREENIM_MODEL"
228+
export ANTHROPIC_DEFAULT_OPUS_MODEL="$FREENIM_MODEL"
229+
export ANTHROPIC_DEFAULT_SONNET_MODEL="$FREENIM_MODEL"
230+
export ANTHROPIC_DEFAULT_HAIKU_MODEL="$FREENIM_MODEL"
231+
export ANTHROPIC_CUSTOM_MODEL_OPTION="$FREENIM_MODEL"
232+
export ANTHROPIC_CUSTOM_MODEL_OPTION_NAME="FreeNIMAPI / $FREENIM_MODEL"
233+
export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
234+
claude --model "$FREENIM_MODEL"
224235
```
225236

237+
On the first launch, Claude Code may still ask for a theme, show its security
238+
notice, and ask whether you trust the folder. Those prompts are expected; the
239+
login-method screen is not. `/status` should show `http://127.0.0.1:3000` and
240+
`ANTHROPIC_AUTH_TOKEN`, while `/model` includes `FreeNIMAPI / nim-auto`. To use
241+
another model, set something like `export FREENIM_MODEL=minimax-m3` before the
242+
block.
243+
226244
This is technical compatibility through Claude Code's official LLM gateway mechanism. Anthropic does not guarantee the behavior of non-Claude models.
227245

228246
</details>

README.md

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -213,16 +213,34 @@ FreeNIMAPI отвечает на Codex-запрос каталога специ
213213

214214
`ANTHROPIC_BASE_URL` указывает на корень сервера, **без** `/v1`:
215215

216+
Не выбирайте пункт на экране входа Claude Code: произвольный gateway не
217+
добавляется через это меню. Остановите этот экран через `Ctrl+C`, затем целиком
218+
вставьте блок ниже в **тот же терминал**, из которого будете запускать `claude`.
219+
Блок сам задаёт loopback-ключ guided-режима, даже если вы не выполнили общий
220+
`export FREENIM_LOCAL_API_KEY` выше:
221+
216222
```bash
223+
export FREENIM_LOCAL_API_KEY="${FREENIM_LOCAL_API_KEY:-freenim-local}"
224+
export FREENIM_MODEL="${FREENIM_MODEL:-nim-auto}"
217225
export ANTHROPIC_BASE_URL=http://127.0.0.1:3000
218226
export ANTHROPIC_AUTH_TOKEN="$FREENIM_LOCAL_API_KEY"
219-
export ANTHROPIC_MODEL=nim-auto
220-
export ANTHROPIC_DEFAULT_OPUS_MODEL=nim-auto
221-
export ANTHROPIC_DEFAULT_SONNET_MODEL=nim-auto
222-
export ANTHROPIC_DEFAULT_HAIKU_MODEL=nim-auto
223-
claude
227+
export ANTHROPIC_MODEL="$FREENIM_MODEL"
228+
export ANTHROPIC_DEFAULT_OPUS_MODEL="$FREENIM_MODEL"
229+
export ANTHROPIC_DEFAULT_SONNET_MODEL="$FREENIM_MODEL"
230+
export ANTHROPIC_DEFAULT_HAIKU_MODEL="$FREENIM_MODEL"
231+
export ANTHROPIC_CUSTOM_MODEL_OPTION="$FREENIM_MODEL"
232+
export ANTHROPIC_CUSTOM_MODEL_OPTION_NAME="FreeNIMAPI / $FREENIM_MODEL"
233+
export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
234+
claude --model "$FREENIM_MODEL"
224235
```
225236

237+
При первом запуске могут остаться выбор темы, предупреждение безопасности и
238+
подтверждение доверия папке — это нормально. Экрана выбора способа входа быть
239+
не должно. В `/status` должны отображаться адрес `http://127.0.0.1:3000` и
240+
источник токена `ANTHROPIC_AUTH_TOKEN`; модель также появится в `/model` как
241+
`FreeNIMAPI / nim-auto`. Для другой модели перед блоком задайте, например,
242+
`export FREENIM_MODEL=minimax-m3`.
243+
226244
Это техническая совместимость через официальный механизм Claude Code LLM gateway. Anthropic не гарантирует поведение не-Claude моделей.
227245

228246
</details>

README.zh-CN.md

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -213,16 +213,33 @@ FreeNIMAPI 会为 Codex 的模型目录请求返回专用的 `ModelInfo` 元数
213213

214214
`ANTHROPIC_BASE_URL` 必须指向服务器根地址,**不能**包含 `/v1`:
215215

216+
不要在 Claude Code 的登录界面中选择任何选项:任意自定义 gateway 并不是
217+
通过该菜单配置的。请先按 `Ctrl+C` 退出,然后把下面的完整代码块粘贴到
218+
之后用于启动 `claude` 的**同一个终端**。即使你跳过了上面的通用
219+
`export FREENIM_LOCAL_API_KEY`,此代码块也会自动设置 guided 模式的本地
220+
loopback 密钥:
221+
216222
```bash
223+
export FREENIM_LOCAL_API_KEY="${FREENIM_LOCAL_API_KEY:-freenim-local}"
224+
export FREENIM_MODEL="${FREENIM_MODEL:-nim-auto}"
217225
export ANTHROPIC_BASE_URL=http://127.0.0.1:3000
218226
export ANTHROPIC_AUTH_TOKEN="$FREENIM_LOCAL_API_KEY"
219-
export ANTHROPIC_MODEL=nim-auto
220-
export ANTHROPIC_DEFAULT_OPUS_MODEL=nim-auto
221-
export ANTHROPIC_DEFAULT_SONNET_MODEL=nim-auto
222-
export ANTHROPIC_DEFAULT_HAIKU_MODEL=nim-auto
223-
claude
227+
export ANTHROPIC_MODEL="$FREENIM_MODEL"
228+
export ANTHROPIC_DEFAULT_OPUS_MODEL="$FREENIM_MODEL"
229+
export ANTHROPIC_DEFAULT_SONNET_MODEL="$FREENIM_MODEL"
230+
export ANTHROPIC_DEFAULT_HAIKU_MODEL="$FREENIM_MODEL"
231+
export ANTHROPIC_CUSTOM_MODEL_OPTION="$FREENIM_MODEL"
232+
export ANTHROPIC_CUSTOM_MODEL_OPTION_NAME="FreeNIMAPI / $FREENIM_MODEL"
233+
export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
234+
claude --model "$FREENIM_MODEL"
224235
```
225236

237+
首次启动时仍可能出现主题选择、安全提示以及工作区信任确认,这些都属于
238+
正常流程;不应再出现登录方式选择界面。`/status` 应显示
239+
`http://127.0.0.1:3000` 和凭据来源 `ANTHROPIC_AUTH_TOKEN`,`/model` 中则会
240+
出现 `FreeNIMAPI / nim-auto`。若要使用其他模型,可在运行代码块前设置,
241+
例如 `export FREENIM_MODEL=minimax-m3`。
242+
226243
该接入基于 Claude Code 官方的 LLM gateway 机制实现。Anthropic 不保证非 Claude 模型能够表现出相同的行为。
227244

228245
</details>

docs/AGENT_E2E.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ satisfy the pass gate.
2626

2727
The runner then checks all of the following independently:
2828

29+
- a cold-start compatibility preflight reproduces Hermes local-server discovery,
30+
lists the model catalog, and retrieves the selected model through
31+
`GET /v1/models/{model}` before the first generation;
32+
- a runner-owned proxy emits zero unexpected `WARN` or `ERROR` entries during
33+
startup, model discovery, tool preflight, and the complete agent turn;
2934
- client process exit code is zero;
3035
- a real command/edit tool appears in the client trace;
3136
- production source changed;

scripts/agent_e2e.js

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -847,6 +847,8 @@ async function clientInvocation(
847847
ANTHROPIC_DEFAULT_OPUS_MODEL: model,
848848
ANTHROPIC_DEFAULT_SONNET_MODEL: model,
849849
ANTHROPIC_DEFAULT_HAIKU_MODEL: model,
850+
ANTHROPIC_CUSTOM_MODEL_OPTION: model,
851+
ANTHROPIC_CUSTOM_MODEL_OPTION_NAME: `FreeNIMAPI / ${model}`,
850852
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
851853
CLAUDE_TMPDIR: path.join(runRoot, "tmp"),
852854
CLAUDE_CODE_TMPDIR: path.join(runRoot, "tmp"),
@@ -963,9 +965,18 @@ async function startProxy(model) {
963965
`live-nvidia-nim requires ${OFFICIAL_NVIDIA_UPSTREAM_ORIGIN}; got ${upstreamOrigin}`,
964966
);
965967
}
968+
const logCounts = { warn: 0, error: 0 };
966969
const server = createFreeNimServer({
967970
config,
968-
logger: { info() {}, warn() {}, error() {} },
971+
logger: {
972+
info() {},
973+
warn() {
974+
logCounts.warn += 1;
975+
},
976+
error() {
977+
logCounts.error += 1;
978+
},
979+
},
969980
});
970981
await new Promise((resolve, reject) => {
971982
server.once("error", reject);
@@ -975,6 +986,65 @@ async function startProxy(model) {
975986
server,
976987
baseUrl: `http://127.0.0.1:${server.address().port}`,
977988
upstreamOrigin,
989+
logCounts,
990+
};
991+
}
992+
993+
async function startupCompatibilityPreflight(baseUrl, model) {
994+
const headers = { authorization: `Bearer ${LOCAL_KEY}` };
995+
const optionalProbePaths = [
996+
"/api/v1/models",
997+
"/api/tags",
998+
"/v1/props",
999+
"/props",
1000+
"/version",
1001+
];
1002+
const optionalProbes = {};
1003+
1004+
for (const probePath of optionalProbePaths) {
1005+
const response = await fetch(`${baseUrl}${probePath}`, { headers });
1006+
optionalProbes[probePath] = response.status;
1007+
await response.text();
1008+
if (response.status !== 404) {
1009+
throw new Error(
1010+
`Local-server discovery probe ${probePath} must return a truthful HTTP 404; got ${response.status}`,
1011+
);
1012+
}
1013+
}
1014+
1015+
const listResponse = await fetch(`${baseUrl}/v1/models`, { headers });
1016+
if (!listResponse.ok) {
1017+
throw new Error(`Model catalog preflight failed with HTTP ${listResponse.status}`);
1018+
}
1019+
const catalog = await listResponse.json();
1020+
if (catalog?.object !== "list" || !Array.isArray(catalog?.data)) {
1021+
throw new Error("Model catalog preflight returned an invalid OpenAI-compatible payload");
1022+
}
1023+
1024+
const detailResponse = await fetch(
1025+
`${baseUrl}/v1/models/${encodeURIComponent(model)}`,
1026+
{ headers },
1027+
);
1028+
if (!detailResponse.ok) {
1029+
throw new Error(
1030+
`Hermes model metadata preflight for '${model}' failed with HTTP ${detailResponse.status}`,
1031+
);
1032+
}
1033+
const detail = await detailResponse.json();
1034+
if (detail?.object !== "model" || typeof detail?.id !== "string") {
1035+
throw new Error("Hermes model metadata preflight returned an invalid model object");
1036+
}
1037+
if (!catalog.data.some((entry) => entry?.id === detail.id)) {
1038+
throw new Error(`Retrieved model '${detail.id}' is missing from the model catalog`);
1039+
}
1040+
1041+
return {
1042+
passed: true,
1043+
model_requested: model,
1044+
model_resolved: detail.id,
1045+
list_status: listResponse.status,
1046+
detail_status: detailResponse.status,
1047+
optional_probe_statuses: optionalProbes,
9781048
};
9791049
}
9801050

@@ -1317,6 +1387,8 @@ function markdownReport(report) {
13171387
"Run the normal Hermes E2E separately for completed-tool ordering evidence.",
13181388
"",
13191389
`- Hermes process: ${mark(result?.checks?.client_exit)}`,
1390+
`- Hermes startup compatibility: ${mark(report.startup_compatibility?.passed)}`,
1391+
`- Clean proxy log: ${report.proxy_logs?.observable ? mark(report.proxy_logs.clean) : "NOT OBSERVABLE"}`,
13201392
`- Initial fixture RED: ${mark(result?.checks?.initial_fixture_red)}`,
13211393
`- Production source changed: ${mark(result?.checks?.source_changed)}`,
13221394
`- Contract protected: ${mark(result?.checks?.contract_unchanged)}`,
@@ -1338,6 +1410,8 @@ function markdownReport(report) {
13381410
`- Text tool fallback: disabled`,
13391411
`- Structural tool preflight: ${report.preflight.structural_tool_call ? "PASS" : "FAIL"}`,
13401412
`- Tool continuation preflight: ${report.preflight.tool_continuation ? "PASS" : "FAIL"}`,
1413+
`- Hermes startup compatibility: ${report.startup_compatibility?.passed ? "PASS" : "FAIL"}`,
1414+
`- Clean proxy log: ${report.proxy_logs?.observable ? (report.proxy_logs.clean ? "PASS" : "FAIL") : "NOT OBSERVABLE"}`,
13411415
"",
13421416
"| Client | Result | Initial RED | Failure→edit→green | Source changed | Contract protected | Independent tests | Final turn |",
13431417
"|---|---:|---:|---:|---:|---:|---:|---:|",
@@ -1371,6 +1445,7 @@ async function main() {
13711445
requireLoopbackProxy(baseUrl);
13721446

13731447
try {
1448+
const startupCompatibility = await startupCompatibilityPreflight(baseUrl, options.model);
13741449
const preflight = await livePreflight(
13751450
baseUrl,
13761451
options.model,
@@ -1409,6 +1484,19 @@ async function main() {
14091484
}
14101485
}
14111486

1487+
const proxyLogs = ownedProxy
1488+
? {
1489+
observable: true,
1490+
warn_count: ownedProxy.logCounts.warn,
1491+
error_count: ownedProxy.logCounts.error,
1492+
clean: ownedProxy.logCounts.warn === 0 && ownedProxy.logCounts.error === 0,
1493+
}
1494+
: {
1495+
observable: false,
1496+
warn_count: null,
1497+
error_count: null,
1498+
clean: null,
1499+
};
14121500
const report = {
14131501
timestamp: new Date().toISOString(),
14141502
mode: options.hermesCliDemo ? "visual-hermes-cli-demo" : options.mode,
@@ -1417,9 +1505,15 @@ async function main() {
14171505
proxy_ownership: ownedProxy ? "runner-owned" : "external-unverified",
14181506
upstream_provider: ownedProxy ? "nvidia-nim" : "unverified",
14191507
upstream_origin: ownedProxy?.upstreamOrigin ?? null,
1508+
startup_compatibility: startupCompatibility,
1509+
proxy_logs: proxyLogs,
14201510
preflight,
14211511
results,
1422-
passed: results.length > 0 && results.every((result) => result.passed),
1512+
passed:
1513+
startupCompatibility.passed &&
1514+
(!proxyLogs.observable || proxyLogs.clean) &&
1515+
results.length > 0 &&
1516+
results.every((result) => result.passed),
14231517
};
14241518
await writeFile(path.join(reportDir, "report.json"), `${JSON.stringify(report, null, 2)}\n`);
14251519
await writeFile(path.join(reportDir, "SUMMARY.md"), markdownReport(report));

scripts/check_readme_nominal.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,20 @@ const installBlock = [
3636
"npm ci",
3737
"npm run start:guided",
3838
].join("\n");
39+
const claudeGatewayBlock = [
40+
'export FREENIM_LOCAL_API_KEY="${FREENIM_LOCAL_API_KEY:-freenim-local}"',
41+
'export FREENIM_MODEL="${FREENIM_MODEL:-nim-auto}"',
42+
"export ANTHROPIC_BASE_URL=http://127.0.0.1:3000",
43+
'export ANTHROPIC_AUTH_TOKEN="$FREENIM_LOCAL_API_KEY"',
44+
'export ANTHROPIC_MODEL="$FREENIM_MODEL"',
45+
'export ANTHROPIC_DEFAULT_OPUS_MODEL="$FREENIM_MODEL"',
46+
'export ANTHROPIC_DEFAULT_SONNET_MODEL="$FREENIM_MODEL"',
47+
'export ANTHROPIC_DEFAULT_HAIKU_MODEL="$FREENIM_MODEL"',
48+
'export ANTHROPIC_CUSTOM_MODEL_OPTION="$FREENIM_MODEL"',
49+
'export ANTHROPIC_CUSTOM_MODEL_OPTION_NAME="FreeNIMAPI / $FREENIM_MODEL"',
50+
'export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1',
51+
'claude --model "$FREENIM_MODEL"',
52+
].join("\n");
3953

4054
function fail(message) {
4155
throw new Error(`README nominal failed: ${message}`);
@@ -97,6 +111,9 @@ for (const [name, markdown] of documents) {
97111
}
98112

99113
if (!markdown.includes(installBlock)) fail(`${name} does not contain the four-command install block`);
114+
if (!markdown.includes(claudeGatewayBlock)) {
115+
fail(`${name} does not contain the self-contained Claude Code gateway launch block`);
116+
}
100117
if (!markdown.includes("FREENIM_LOCAL_API_KEY=freenim-local npm run health")) {
101118
fail(`${name} does not contain the documented health smoke`);
102119
}

scripts/mock_agent_e2e.js

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { createFreeNimServer } from "../src/server.js";
1010

1111
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
1212
const LOCAL_KEY = "local-e2e";
13-
const MODEL = "nim-auto";
13+
const MODEL = process.env.FREENIM_MOCK_MODEL || "nim-auto";
1414
const CLIENTS = process.env.FREENIM_MOCK_CLIENTS || "hermes,codex,claude,opencode-chat,opencode-responses";
1515
const DEBUG = process.env.FREENIM_MOCK_DEBUG === "1";
1616
const HERMES_CLI_DEMO = process.env.FREENIM_MOCK_HERMES_CLI_DEMO === "1";
@@ -337,6 +337,7 @@ async function main() {
337337
responseCache.clear();
338338
const clientReportDir = path.join(reportDir, client);
339339
await mkdir(clientReportDir, { recursive: true });
340+
const proxyLogCounts = { warn: 0, error: 0 };
340341
const server = createFreeNimServer({
341342
config: {
342343
...loadConfig({}),
@@ -353,7 +354,15 @@ async function main() {
353354
? ({ method, path: requestPath, query }) =>
354355
console.error(`[FreeNIMAPI mock debug] downstream ${method} ${requestPath}${query}`)
355356
: null,
356-
logger: { info() {}, warn() {}, error() {} },
357+
logger: {
358+
info() {},
359+
warn() {
360+
proxyLogCounts.warn += 1;
361+
},
362+
error() {
363+
proxyLogCounts.error += 1;
364+
},
365+
},
357366
});
358367
await new Promise((resolve, reject) => {
359368
server.once("error", reject);
@@ -392,6 +401,19 @@ async function main() {
392401
}
393402

394403
const report = JSON.parse(await readFile(path.join(clientReportDir, "report.json"), "utf8"));
404+
const cleanProxyLog = proxyLogCounts.warn === 0 && proxyLogCounts.error === 0;
405+
report.proxy_logs = {
406+
observable: true,
407+
warn_count: proxyLogCounts.warn,
408+
error_count: proxyLogCounts.error,
409+
clean: cleanProxyLog,
410+
};
411+
report.results[0].checks.clean_proxy_log = cleanProxyLog;
412+
if (!cleanProxyLog) {
413+
report.passed = false;
414+
report.results[0].passed = false;
415+
report.results[0].error ||= "FreeNIMAPI emitted WARN or ERROR during the client run";
416+
}
395417
if (client === "codex" && !sawCodexApplyPatch) {
396418
report.passed = false;
397419
report.results[0].passed = false;

0 commit comments

Comments
 (0)