feat(FN-3305): add models.json API key fallback resolution
The merge restores Claude usage tracking by introducing a Proxy-based auth storage with a fallback resolver that falls back to `models.json` API keys when the primary auth store lacks credentials. It also adds planning improvements with corresponding tests and a context limit detector enhancement, a Fusion-Task-Id: FN-3305
This commit is contained in:
5
.changeset/fix-models-json-api-key-resolution.md
Normal file
5
.changeset/fix-models-json-api-key-resolution.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix custom model providers (e.g., Kimi, LM Studio, Ollama) failing with "No API key" error. The auth storage proxy now reads API keys from models.json as a fallback, and a Proxy set trap ensures the ModelRegistry's fallback resolver works correctly through the proxy.
|
||||||
@@ -94,4 +94,186 @@ describe("createFusionAuthStorage", () => {
|
|||||||
expect(existsSync(join(homeDir, ".pi", "agent", "auth.json"))).toBe(false);
|
expect(existsSync(join(homeDir, ".pi", "agent", "auth.json"))).toBe(false);
|
||||||
expect(existsSync(join(homeDir, ".pi", "auth.json"))).toBe(false);
|
expect(existsSync(join(homeDir, ".pi", "auth.json"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("models.json API key fallback", () => {
|
||||||
|
it("returns API key from models.json when not in auth.json", async () => {
|
||||||
|
const legacyAgentDir = join(homeDir, ".pi", "agent");
|
||||||
|
mkdirSync(legacyAgentDir, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(legacyAgentDir, "models.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
providers: {
|
||||||
|
"kimi-coding": {
|
||||||
|
api: "openai-completions",
|
||||||
|
apiKey: "kimi-api-key-123",
|
||||||
|
baseUrl: "https://api.kimi.com/coding/v1",
|
||||||
|
models: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const authStorage = createFusionAuthStorage();
|
||||||
|
|
||||||
|
expect(await authStorage.getApiKey("kimi-coding")).toBe("kimi-api-key-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns hasAuth=true for provider with key only in models.json", async () => {
|
||||||
|
const legacyAgentDir = join(homeDir, ".pi", "agent");
|
||||||
|
mkdirSync(legacyAgentDir, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(legacyAgentDir, "models.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
providers: {
|
||||||
|
"kimi-coding": {
|
||||||
|
api: "openai-completions",
|
||||||
|
apiKey: "kimi-api-key-123",
|
||||||
|
baseUrl: "https://api.kimi.com/coding/v1",
|
||||||
|
models: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const authStorage = createFusionAuthStorage();
|
||||||
|
|
||||||
|
expect(authStorage.hasAuth("kimi-coding")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes models.json providers in list()", async () => {
|
||||||
|
const legacyAgentDir = join(homeDir, ".pi", "agent");
|
||||||
|
mkdirSync(legacyAgentDir, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(legacyAgentDir, "auth.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
openrouter: { type: "api_key", key: "openrouter-key" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(legacyAgentDir, "models.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
providers: {
|
||||||
|
"kimi-coding": { apiKey: "kimi-key" },
|
||||||
|
lmstudio: { apiKey: "lm-key" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const authStorage = createFusionAuthStorage();
|
||||||
|
const providers = authStorage.list();
|
||||||
|
|
||||||
|
expect(providers).toContain("openrouter");
|
||||||
|
expect(providers).toContain("kimi-coding");
|
||||||
|
expect(providers).toContain("lmstudio");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auth.json keys take precedence over models.json keys", async () => {
|
||||||
|
const legacyAgentDir = join(homeDir, ".pi", "agent");
|
||||||
|
mkdirSync(legacyAgentDir, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(legacyAgentDir, "auth.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
"kimi-coding": { type: "api_key", key: "auth-json-key" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(legacyAgentDir, "models.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
providers: {
|
||||||
|
"kimi-coding": { apiKey: "models-json-key" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const authStorage = createFusionAuthStorage();
|
||||||
|
|
||||||
|
// auth.json key should take precedence
|
||||||
|
expect(await authStorage.getApiKey("kimi-coding")).toBe("auth-json-key");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reload() picks up changes to models.json", async () => {
|
||||||
|
const legacyAgentDir = join(homeDir, ".pi", "agent");
|
||||||
|
mkdirSync(legacyAgentDir, { recursive: true });
|
||||||
|
|
||||||
|
// Initially no models.json
|
||||||
|
const authStorage = createFusionAuthStorage();
|
||||||
|
expect(await authStorage.getApiKey("kimi-coding")).toBeUndefined();
|
||||||
|
|
||||||
|
// Write models.json and reload
|
||||||
|
writeFileSync(
|
||||||
|
join(legacyAgentDir, "models.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
providers: {
|
||||||
|
"kimi-coding": { apiKey: "new-kimi-key" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
authStorage.reload();
|
||||||
|
|
||||||
|
expect(await authStorage.getApiKey("kimi-coding")).toBe("new-kimi-key");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads from Fusion models.json before legacy paths", async () => {
|
||||||
|
// Create both Fusion and legacy models.json
|
||||||
|
const fusionAgentDir = join(homeDir, ".fusion", "agent");
|
||||||
|
const legacyAgentDir = join(homeDir, ".pi", "agent");
|
||||||
|
mkdirSync(fusionAgentDir, { recursive: true });
|
||||||
|
mkdirSync(legacyAgentDir, { recursive: true });
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
join(fusionAgentDir, "models.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
providers: {
|
||||||
|
"kimi-coding": { apiKey: "fusion-models-key" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(legacyAgentDir, "models.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
providers: {
|
||||||
|
"kimi-coding": { apiKey: "legacy-models-key" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const authStorage = createFusionAuthStorage();
|
||||||
|
|
||||||
|
expect(await authStorage.getApiKey("kimi-coding")).toBe("fusion-models-key");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has() returns true for provider with key only in models.json", async () => {
|
||||||
|
const legacyAgentDir = join(homeDir, ".pi", "agent");
|
||||||
|
mkdirSync(legacyAgentDir, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(legacyAgentDir, "models.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
providers: {
|
||||||
|
ollama: { apiKey: "ollama-key" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const authStorage = createFusionAuthStorage();
|
||||||
|
|
||||||
|
expect(authStorage.has("ollama")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards setFallbackResolver to the underlying AuthStorage", async () => {
|
||||||
|
const authStorage = createFusionAuthStorage();
|
||||||
|
|
||||||
|
// Set a fallback resolver (this is what ModelRegistry does in its constructor)
|
||||||
|
// Without the Proxy `set` trap, this would write to the Proxy object instead
|
||||||
|
// of the underlying AuthStorage, making the resolver invisible to getApiKey().
|
||||||
|
(authStorage as any).setFallbackResolver((provider: string) => {
|
||||||
|
if (provider === "dynamic-provider") return "dynamic-api-key";
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await authStorage.getApiKey("dynamic-provider")).toBe("dynamic-api-key");
|
||||||
|
expect(await authStorage.getApiKey("unknown-provider")).toBeUndefined();
|
||||||
|
expect(authStorage.hasAuth("dynamic-provider")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -98,16 +98,64 @@ function resolveStoredCredentialApiKey(providerId: string, credential: StoredCre
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads API keys from the resolved models.json file.
|
||||||
|
*
|
||||||
|
* Some providers (e.g., kimi-coding, lmstudio, ollama) store their API keys
|
||||||
|
* in `models.json` under `providers.<providerId>.apiKey` rather than in
|
||||||
|
* `auth.json`. This function extracts those keys so the auth storage proxy
|
||||||
|
* can return them as a fallback when neither Fusion auth nor legacy auth.json
|
||||||
|
* contains a key for the provider.
|
||||||
|
*/
|
||||||
|
function readModelsJsonApiKeys(home = getHomeDir()): Map<string, string> {
|
||||||
|
const apiKeys = new Map<string, string>();
|
||||||
|
const modelsPath = getModelRegistryModelsPath(home);
|
||||||
|
|
||||||
|
if (!existsSync(modelsPath)) {
|
||||||
|
return apiKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(modelsPath, "utf-8")) as {
|
||||||
|
providers?: Record<string, { apiKey?: string }>;
|
||||||
|
};
|
||||||
|
const providers = parsed?.providers;
|
||||||
|
if (providers) {
|
||||||
|
for (const [providerId, config] of Object.entries(providers)) {
|
||||||
|
if (config.apiKey) {
|
||||||
|
apiKeys.set(providerId, config.apiKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore invalid models.json files.
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiKeys;
|
||||||
|
}
|
||||||
|
|
||||||
export function createFusionAuthStorage(): AuthStorage {
|
export function createFusionAuthStorage(): AuthStorage {
|
||||||
const primary = AuthStorage.create(getFusionAuthPath());
|
const primary = AuthStorage.create(getFusionAuthPath());
|
||||||
let legacyCredentials = readLegacyCredentials();
|
let legacyCredentials = readLegacyCredentials();
|
||||||
|
// models.json provider API keys — third fallback after primary auth and legacy auth.json
|
||||||
|
let modelsJsonApiKeys = readModelsJsonApiKeys();
|
||||||
|
|
||||||
return new Proxy(primary, {
|
return new Proxy(primary, {
|
||||||
|
// Forward property writes to the target so that methods like
|
||||||
|
// `setFallbackResolver` (called by ModelRegistry) correctly update the
|
||||||
|
// underlying AuthStorage. Without this trap, writes land on the Proxy
|
||||||
|
// object itself and the target's fallbackResolver stays undefined.
|
||||||
|
set(target: AuthStorage, prop: string | symbol, value: unknown) {
|
||||||
|
(target as Record<string | symbol, unknown>)[prop] = value;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
get(target, prop, receiver) {
|
get(target, prop, receiver) {
|
||||||
if (prop === "reload") {
|
if (prop === "reload") {
|
||||||
return () => {
|
return () => {
|
||||||
target.reload();
|
target.reload();
|
||||||
legacyCredentials = readLegacyCredentials();
|
legacyCredentials = readLegacyCredentials();
|
||||||
|
modelsJsonApiKeys = readModelsJsonApiKeys();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,11 +164,11 @@ export function createFusionAuthStorage(): AuthStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (prop === "has") {
|
if (prop === "has") {
|
||||||
return (provider: string) => target.has(provider) || provider in legacyCredentials;
|
return (provider: string) => target.has(provider) || provider in legacyCredentials || modelsJsonApiKeys.has(provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prop === "hasAuth") {
|
if (prop === "hasAuth") {
|
||||||
return (provider: string) => target.hasAuth(provider) || Boolean(legacyCredentials[provider]);
|
return (provider: string) => target.hasAuth(provider) || Boolean(legacyCredentials[provider]) || modelsJsonApiKeys.has(provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prop === "getAll") {
|
if (prop === "getAll") {
|
||||||
@@ -128,15 +176,21 @@ export function createFusionAuthStorage(): AuthStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (prop === "list") {
|
if (prop === "list") {
|
||||||
return () => Array.from(new Set([...Object.keys(legacyCredentials), ...target.list()]));
|
return () => Array.from(new Set([...Object.keys(legacyCredentials), ...target.list(), ...modelsJsonApiKeys.keys()]));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prop === "getApiKey") {
|
if (prop === "getApiKey") {
|
||||||
return async (provider: string) => {
|
return async (provider: string) => {
|
||||||
|
// 1. Primary Fusion auth
|
||||||
const primaryKey = await target.getApiKey(provider);
|
const primaryKey = await target.getApiKey(provider);
|
||||||
if (primaryKey) return primaryKey;
|
if (primaryKey) return primaryKey;
|
||||||
|
|
||||||
return resolveStoredCredentialApiKey(provider, legacyCredentials[provider]);
|
// 2. Legacy auth.json credentials
|
||||||
|
const legacyKey = resolveStoredCredentialApiKey(provider, legacyCredentials[provider]);
|
||||||
|
if (legacyKey) return legacyKey;
|
||||||
|
|
||||||
|
// 3. models.json provider API keys (e.g., kimi-coding, lmstudio)
|
||||||
|
return modelsJsonApiKeys.get(provider);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user