feat(ui): profils locaux/LAN OpenAI-compatible dans le first-run et les terminaux (#14)

Câble la surface frontend des profils IA locaux/LAN OpenAI-compatible,
en parité avec l'adapter backend additif (aab4bca).

- domain: types de profil OpenAI-compatible
- first-run: édition/validation du profil dans le FirstRunWizard
- adapters/mock: mock de profil pour les tests
- terminals: rendu des round-trips et erreurs endpoint (role=alert)

Validé QA (frontend GO): typecheck exit 0, vitest 59 fichiers / 566 tests,
0 echec ; couverture timeouts round-trip + erreur endpoint role=alert
prouvees ; pas de regression sur les tests de bail headless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 22:34:50 +02:00
parent aab4bcafb6
commit d89380cdf0
9 changed files with 656 additions and 10 deletions

View File

@ -7,12 +7,15 @@ import { describe, it, expect } from "vitest";
import type { AgentProfile } from "@/domain";
import {
defaultHttpChatConfig,
defaultInjection,
emptyCustomProfile,
isProfileValid,
isRelativeSafe,
isValidEnvVar,
isValidHttpUrl,
parseArgs,
validateHttpChatConfig,
validateProfile,
} from "./profile";
@ -93,6 +96,99 @@ describe("validateProfile / isProfileValid", () => {
});
});
describe("isValidHttpUrl", () => {
it("accepts http/https endpoints", () => {
expect(isValidHttpUrl("http://localhost:11434/v1")).toBe(true);
expect(isValidHttpUrl("https://lan.host:8080/v1")).toBe(true);
});
it("rejects empty, non-http and other schemes", () => {
expect(isValidHttpUrl("")).toBe(false);
expect(isValidHttpUrl(" ")).toBe(false);
expect(isValidHttpUrl("ftp://host")).toBe(false);
expect(isValidHttpUrl("localhost:11434")).toBe(false);
// Leading whitespace fails the raw prefix check, mirroring the backend.
expect(isValidHttpUrl(" http://host")).toBe(false);
});
});
describe("validateHttpChatConfig", () => {
it("a well-formed local model config is valid", () => {
expect(validateHttpChatConfig(defaultHttpChatConfig())).toEqual({});
});
it("reports a bad endpoint scheme and an empty model", () => {
const errors = validateHttpChatConfig({ endpoint: "ftp://x", model: " " });
expect(errors.endpoint).toBeDefined();
expect(errors.model).toBeDefined();
});
it("apiKeyEnv must be an env var NAME, never a raw key", () => {
// A plausible raw key contains characters illegal in an env var identifier.
const withKey = validateHttpChatConfig({
endpoint: "http://host",
model: "m",
apiKeyEnv: "sk-abc123",
});
expect(withKey.apiKeyEnv).toBeDefined();
// A valid identifier passes; blank/undefined is allowed (optional).
expect(
validateHttpChatConfig({
endpoint: "http://host",
model: "m",
apiKeyEnv: "OPENAI_API_KEY",
}).apiKeyEnv,
).toBeUndefined();
expect(
validateHttpChatConfig({ endpoint: "http://host", model: "m" }).apiKeyEnv,
).toBeUndefined();
});
it("rejects zero / non-integer timeouts and tool-iteration guard", () => {
const errors = validateHttpChatConfig({
endpoint: "http://host",
model: "m",
requestTimeoutMs: 0,
connectTimeoutMs: -1,
maxToolIterations: 1.5,
});
expect(errors.requestTimeoutMs).toBeDefined();
expect(errors.connectTimeoutMs).toBeDefined();
expect(errors.maxToolIterations).toBeDefined();
});
});
describe("validateProfile for openAiCompatible profiles", () => {
function ollama(overrides: Partial<AgentProfile> = {}): AgentProfile {
return base({
name: "Ollama",
command: "openai-compatible",
structuredAdapter: "openAiCompatible",
chatHttp: defaultHttpChatConfig(),
...overrides,
});
}
it("the reference local-model profile is valid", () => {
expect(validateProfile(ollama())).toEqual({});
expect(isProfileValid(ollama())).toBe(true);
});
it("surfaces chatHttp field errors through the profile validator", () => {
const errors = validateProfile(
ollama({ chatHttp: { endpoint: "nope", model: "" } }),
);
expect(errors.endpoint).toBeDefined();
expect(errors.model).toBeDefined();
expect(isProfileValid(ollama({ chatHttp: { endpoint: "nope", model: "m" } }))).toBe(
false,
);
});
it("a missing chatHttp on an openAiCompatible profile is invalid", () => {
const errors = validateProfile(ollama({ chatHttp: undefined }));
expect(errors.endpoint).toBeDefined();
expect(errors.model).toBeDefined();
});
it("does not touch validation of Claude/Codex (non-structured-http) profiles", () => {
// A plain profile with no structuredAdapter never triggers chatHttp checks.
expect(validateProfile(base())).toEqual({});
});
});
describe("defaultInjection", () => {
it("produces a sensible default per strategy", () => {
expect(defaultInjection("conventionFile")).toEqual({