@gitpagedocs/tools 1.1.44
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/README.md +33 -0
- package/package.json +37 -0
- package/src/ai/bootstrap.ts +50 -0
- package/src/ai/catalog.ts +188 -0
- package/src/ai/factory.ts +14 -0
- package/src/ai/http/streaming.ts +60 -0
- package/src/ai/index.ts +27 -0
- package/src/ai/legacy-adapter.ts +33 -0
- package/src/ai/model-registry.ts +13 -0
- package/src/ai/providers/anthropic-provider.ts +83 -0
- package/src/ai/providers/cohere-provider.ts +66 -0
- package/src/ai/providers/gemini-provider.ts +84 -0
- package/src/ai/providers/ollama-provider.ts +69 -0
- package/src/ai/providers/openai-compatible-provider.ts +75 -0
- package/src/ai/providers/shared.ts +72 -0
- package/src/ai/registry.ts +29 -0
- package/src/cache/file-cache.ts +86 -0
- package/src/cache/index.ts +9 -0
- package/src/cache/memory-cache.ts +47 -0
- package/src/cache/web-storage-cache.ts +91 -0
- package/src/config/config-loader.ts +59 -0
- package/src/config/index.ts +1 -0
- package/src/constants/config.ts +8 -0
- package/src/constants/index.ts +1 -0
- package/src/crypto/index.ts +2 -0
- package/src/crypto/node-crypto-service.ts +87 -0
- package/src/crypto/web-crypto-service.ts +102 -0
- package/src/crypto/web.ts +2 -0
- package/src/documentation/doc-generator.ts +74 -0
- package/src/documentation/doc-updater.ts +47 -0
- package/src/documentation/index.ts +18 -0
- package/src/documentation/marker-patcher.ts +33 -0
- package/src/documentation/sections.ts +82 -0
- package/src/errors/app-error.ts +84 -0
- package/src/errors/index.ts +1 -0
- package/src/filesystem/file-service.ts +121 -0
- package/src/filesystem/index.ts +2 -0
- package/src/index.ts +24 -0
- package/src/logger/index.ts +2 -0
- package/src/logger/logger.ts +73 -0
- package/src/logger/redaction.ts +43 -0
- package/src/ports/ai.ts +109 -0
- package/src/ports/cache.ts +16 -0
- package/src/ports/config.ts +21 -0
- package/src/ports/crypto.ts +33 -0
- package/src/ports/index.ts +15 -0
- package/src/ports/logger.ts +23 -0
- package/src/ports/security.ts +42 -0
- package/src/security/credential-vault.ts +117 -0
- package/src/security/file-vault-storage.ts +25 -0
- package/src/security/index.ts +8 -0
- package/src/security/migrate-plaintext-key.ts +38 -0
- package/src/security/password-gate.ts +62 -0
- package/src/security/web-storage-vault-storage.ts +23 -0
- package/src/security/web.ts +9 -0
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# @gitpagedocs/tools
|
|
2
|
+
|
|
3
|
+
The **shared business-logic core** of the [Git Page Docs](../README.md) monorepo — the single home for domain logic consumed by the frontend, the CLI, and the MCP server. It ships **TypeScript source** and is consumed via `tsx` (CLI/MCP) or a TS-aware bundler (Next `transpilePackages`).
|
|
4
|
+
|
|
5
|
+
## Modules (`src/`)
|
|
6
|
+
|
|
7
|
+
| Module | Responsibility |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `ai/` | 14-provider AI system — data-driven `PROVIDER_CATALOG`, registry + factory (no switch chains), 5 family adapters (OpenAI-compatible, Anthropic, Gemini, Ollama, Cohere), SSE/NDJSON streaming, legacy↔catalog id mapping |
|
|
10
|
+
| `security/` | `EncryptedCredentialVault` (AES-256-GCM), `SessionPasswordGate`, plaintext migration; web + file storage adapters |
|
|
11
|
+
| `crypto/` | `NodeCryptoService` + `WebCryptoService` (SHA-256, PBKDF2, AES-256-GCM, masking, secure wipe) |
|
|
12
|
+
| `cache/` | `MemoryCache`, `FileCache`, `WebStorageCache` (Strategy over one `Cache` port) |
|
|
13
|
+
| `config/` | `GitPageDocsConfigLoader` (`.json` / `.js` / `.ts`) |
|
|
14
|
+
| `logger/` | Level-filtered logger with **secret redaction** |
|
|
15
|
+
| `errors/` | `AppError` + typed subclasses |
|
|
16
|
+
| `filesystem/` | `FileService` (root-bounded list/read/write/search) |
|
|
17
|
+
| `documentation/` | `DocumentationService`, `patchManagedRegion`, `DocUpdater` |
|
|
18
|
+
| `ports/` | Type-only contracts (logger, cache, crypto, security, config, ai) |
|
|
19
|
+
|
|
20
|
+
## Entry points (`exports`)
|
|
21
|
+
|
|
22
|
+
`.` (full barrel) · `./ai` · `./errors` · `./ports` · `./crypto/web` · `./security/web` · `./cache/web` — the `web` subpaths are browser-safe (no `node:` imports) for the Next.js bundle.
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { createDefaultFactory, PROVIDER_CATALOG } from "@gitpagedocs/tools/ai";
|
|
28
|
+
import { EncryptedCredentialVault } from "@gitpagedocs/tools/security/web";
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Tests
|
|
32
|
+
|
|
33
|
+
Unit + integration + E2E live in `tools/tests/**` (Vitest); self-tests in `tools/smoke/**`. Run from the repo root: `npx vitest run` and `npm run smoke:all`.
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gitpagedocs/tools",
|
|
3
|
+
"version": "1.1.44",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Shared business-logic core for Git Page Docs (consumed by frontend, cli and mcp). Ships TypeScript source; consume via tsx or a TS-aware bundler.",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./ai": "./src/ai/index.ts",
|
|
11
|
+
"./errors": "./src/errors/index.ts",
|
|
12
|
+
"./ports": "./src/ports/index.ts",
|
|
13
|
+
"./crypto/web": "./src/crypto/web.ts",
|
|
14
|
+
"./security/web": "./src/security/web.ts",
|
|
15
|
+
"./cache/web": "./src/cache/web-storage-cache.ts"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=20"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/Vidigal-code/git-page-docs.git",
|
|
27
|
+
"directory": "tools"
|
|
28
|
+
},
|
|
29
|
+
"author": "Vidigal-code",
|
|
30
|
+
"license": "ISC",
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { AIProvider, ProviderRegistration } from "../ports/ai";
|
|
2
|
+
import { PROVIDER_CATALOG, type ProviderFamily, type ProviderSpec } from "./catalog";
|
|
3
|
+
import { InMemoryProviderRegistry } from "./registry";
|
|
4
|
+
import { RegistryProviderFactory } from "./factory";
|
|
5
|
+
import { CatalogModelRegistry } from "./model-registry";
|
|
6
|
+
import type { FetchLike } from "./http/streaming";
|
|
7
|
+
import { OpenAiCompatibleProvider } from "./providers/openai-compatible-provider";
|
|
8
|
+
import { AnthropicProvider } from "./providers/anthropic-provider";
|
|
9
|
+
import { GeminiProvider } from "./providers/gemini-provider";
|
|
10
|
+
import { OllamaProvider } from "./providers/ollama-provider";
|
|
11
|
+
import { CohereProvider } from "./providers/cohere-provider";
|
|
12
|
+
|
|
13
|
+
/** Maps a provider family to its adapter constructor (small Factory table). */
|
|
14
|
+
type AdapterCtor = new (spec: ProviderSpec, fetchImpl?: FetchLike) => AIProvider;
|
|
15
|
+
|
|
16
|
+
const FAMILY_ADAPTERS: Record<ProviderFamily, AdapterCtor> = {
|
|
17
|
+
"openai-compatible": OpenAiCompatibleProvider,
|
|
18
|
+
anthropic: AnthropicProvider,
|
|
19
|
+
gemini: GeminiProvider,
|
|
20
|
+
ollama: OllamaProvider,
|
|
21
|
+
cohere: CohereProvider,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function toRegistration(spec: ProviderSpec, fetchImpl?: FetchLike): ProviderRegistration {
|
|
25
|
+
const Adapter = FAMILY_ADAPTERS[spec.family];
|
|
26
|
+
return {
|
|
27
|
+
id: spec.id,
|
|
28
|
+
label: spec.label,
|
|
29
|
+
defaultModel: spec.defaultModel,
|
|
30
|
+
capabilities: spec.capabilities,
|
|
31
|
+
create: () => new Adapter(spec, fetchImpl),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Registry pre-populated with every catalog provider. */
|
|
36
|
+
export function createDefaultRegistry(fetchImpl?: FetchLike): InMemoryProviderRegistry {
|
|
37
|
+
const registry = new InMemoryProviderRegistry();
|
|
38
|
+
for (const spec of Object.values(PROVIDER_CATALOG)) {
|
|
39
|
+
registry.register(toRegistration(spec, fetchImpl));
|
|
40
|
+
}
|
|
41
|
+
return registry;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createDefaultFactory(fetchImpl?: FetchLike): RegistryProviderFactory {
|
|
45
|
+
return new RegistryProviderFactory(createDefaultRegistry(fetchImpl));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createModelRegistry(): CatalogModelRegistry {
|
|
49
|
+
return new CatalogModelRegistry();
|
|
50
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type { AiProviderId, ModelDescriptor, ProviderCapabilities } from "../ports/ai";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Single source of truth for AI providers. Adding a provider = adding a catalog
|
|
5
|
+
* entry; the registry/factory are data-driven, so there is no switch to grow.
|
|
6
|
+
*/
|
|
7
|
+
export type ProviderFamily =
|
|
8
|
+
| "openai-compatible"
|
|
9
|
+
| "anthropic"
|
|
10
|
+
| "gemini"
|
|
11
|
+
| "ollama"
|
|
12
|
+
| "cohere";
|
|
13
|
+
|
|
14
|
+
export type AuthStyle = "bearer" | "x-api-key" | "query-key" | "api-key-header" | "none";
|
|
15
|
+
|
|
16
|
+
export interface ProviderSpec {
|
|
17
|
+
readonly id: AiProviderId;
|
|
18
|
+
readonly label: string;
|
|
19
|
+
readonly family: ProviderFamily;
|
|
20
|
+
readonly defaultModel: string;
|
|
21
|
+
/** Default API base URL (without the chat path). */
|
|
22
|
+
readonly baseUrl: string;
|
|
23
|
+
/** Provider needs a user-supplied baseUrl (Azure deployment, local Ollama). */
|
|
24
|
+
readonly requiresBaseUrl?: boolean;
|
|
25
|
+
readonly auth: AuthStyle;
|
|
26
|
+
readonly capabilities: ProviderCapabilities;
|
|
27
|
+
readonly models: readonly ModelDescriptor[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TEXT: ProviderCapabilities = { streaming: true, vision: false, audio: false };
|
|
31
|
+
const VISION: ProviderCapabilities = { streaming: true, vision: true, audio: false };
|
|
32
|
+
const VISION_AUDIO: ProviderCapabilities = { streaming: true, vision: true, audio: true };
|
|
33
|
+
|
|
34
|
+
function models(...ids: string[]): ModelDescriptor[] {
|
|
35
|
+
return ids.map((id) => ({ id }));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const PROVIDER_CATALOG: Readonly<Record<AiProviderId, ProviderSpec>> = {
|
|
39
|
+
openai: {
|
|
40
|
+
id: "openai",
|
|
41
|
+
label: "OpenAI",
|
|
42
|
+
family: "openai-compatible",
|
|
43
|
+
defaultModel: "gpt-4o-mini",
|
|
44
|
+
baseUrl: "https://api.openai.com/v1",
|
|
45
|
+
auth: "bearer",
|
|
46
|
+
capabilities: VISION,
|
|
47
|
+
models: models("gpt-4o", "gpt-4o-mini", "o3-mini", "o1"),
|
|
48
|
+
},
|
|
49
|
+
anthropic: {
|
|
50
|
+
id: "anthropic",
|
|
51
|
+
label: "Anthropic",
|
|
52
|
+
family: "anthropic",
|
|
53
|
+
// Latest Claude API model IDs.
|
|
54
|
+
defaultModel: "claude-sonnet-4-6",
|
|
55
|
+
baseUrl: "https://api.anthropic.com/v1",
|
|
56
|
+
auth: "x-api-key",
|
|
57
|
+
capabilities: VISION,
|
|
58
|
+
models: models("claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"),
|
|
59
|
+
},
|
|
60
|
+
gemini: {
|
|
61
|
+
id: "gemini",
|
|
62
|
+
label: "Google Gemini",
|
|
63
|
+
family: "gemini",
|
|
64
|
+
defaultModel: "gemini-2.0-flash",
|
|
65
|
+
baseUrl: "https://generativelanguage.googleapis.com/v1beta/models",
|
|
66
|
+
auth: "query-key",
|
|
67
|
+
capabilities: VISION_AUDIO,
|
|
68
|
+
models: models("gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash"),
|
|
69
|
+
},
|
|
70
|
+
openrouter: {
|
|
71
|
+
id: "openrouter",
|
|
72
|
+
label: "OpenRouter",
|
|
73
|
+
family: "openai-compatible",
|
|
74
|
+
defaultModel: "openai/gpt-4o-mini",
|
|
75
|
+
baseUrl: "https://openrouter.ai/api/v1",
|
|
76
|
+
auth: "bearer",
|
|
77
|
+
capabilities: VISION,
|
|
78
|
+
models: models("openai/gpt-4o-mini", "anthropic/claude-sonnet-4-6", "google/gemini-2.0-flash-exp"),
|
|
79
|
+
},
|
|
80
|
+
ollama: {
|
|
81
|
+
id: "ollama",
|
|
82
|
+
label: "Ollama (local)",
|
|
83
|
+
family: "ollama",
|
|
84
|
+
defaultModel: "llama3",
|
|
85
|
+
baseUrl: "http://localhost:11434",
|
|
86
|
+
requiresBaseUrl: false,
|
|
87
|
+
auth: "none",
|
|
88
|
+
capabilities: VISION,
|
|
89
|
+
models: models("llama3", "llama3.1", "mistral", "qwen2.5"),
|
|
90
|
+
},
|
|
91
|
+
"azure-openai": {
|
|
92
|
+
id: "azure-openai",
|
|
93
|
+
label: "Azure OpenAI",
|
|
94
|
+
family: "openai-compatible",
|
|
95
|
+
defaultModel: "gpt-4o-mini",
|
|
96
|
+
baseUrl: "",
|
|
97
|
+
requiresBaseUrl: true,
|
|
98
|
+
auth: "api-key-header",
|
|
99
|
+
capabilities: VISION,
|
|
100
|
+
models: models("gpt-4o", "gpt-4o-mini"),
|
|
101
|
+
},
|
|
102
|
+
mistral: {
|
|
103
|
+
id: "mistral",
|
|
104
|
+
label: "Mistral",
|
|
105
|
+
family: "openai-compatible",
|
|
106
|
+
defaultModel: "mistral-large-latest",
|
|
107
|
+
baseUrl: "https://api.mistral.ai/v1",
|
|
108
|
+
auth: "bearer",
|
|
109
|
+
capabilities: TEXT,
|
|
110
|
+
models: models("mistral-large-latest", "mistral-small-latest", "open-mistral-nemo"),
|
|
111
|
+
},
|
|
112
|
+
deepseek: {
|
|
113
|
+
id: "deepseek",
|
|
114
|
+
label: "DeepSeek",
|
|
115
|
+
family: "openai-compatible",
|
|
116
|
+
defaultModel: "deepseek-chat",
|
|
117
|
+
baseUrl: "https://api.deepseek.com/v1",
|
|
118
|
+
auth: "bearer",
|
|
119
|
+
capabilities: TEXT,
|
|
120
|
+
models: models("deepseek-chat", "deepseek-reasoner"),
|
|
121
|
+
},
|
|
122
|
+
cohere: {
|
|
123
|
+
id: "cohere",
|
|
124
|
+
label: "Cohere",
|
|
125
|
+
family: "cohere",
|
|
126
|
+
defaultModel: "command-r-plus",
|
|
127
|
+
baseUrl: "https://api.cohere.com/v2",
|
|
128
|
+
auth: "bearer",
|
|
129
|
+
capabilities: TEXT,
|
|
130
|
+
models: models("command-r-plus", "command-r"),
|
|
131
|
+
},
|
|
132
|
+
groq: {
|
|
133
|
+
id: "groq",
|
|
134
|
+
label: "Groq",
|
|
135
|
+
family: "openai-compatible",
|
|
136
|
+
defaultModel: "llama-3.3-70b-versatile",
|
|
137
|
+
baseUrl: "https://api.groq.com/openai/v1",
|
|
138
|
+
auth: "bearer",
|
|
139
|
+
capabilities: TEXT,
|
|
140
|
+
models: models("llama-3.3-70b-versatile", "llama-3.1-8b-instant", "mixtral-8x7b-32768"),
|
|
141
|
+
},
|
|
142
|
+
xai: {
|
|
143
|
+
id: "xai",
|
|
144
|
+
label: "xAI Grok",
|
|
145
|
+
family: "openai-compatible",
|
|
146
|
+
defaultModel: "grok-2-latest",
|
|
147
|
+
baseUrl: "https://api.x.ai/v1",
|
|
148
|
+
auth: "bearer",
|
|
149
|
+
capabilities: VISION,
|
|
150
|
+
models: models("grok-2-latest", "grok-2-vision-latest"),
|
|
151
|
+
},
|
|
152
|
+
together: {
|
|
153
|
+
id: "together",
|
|
154
|
+
label: "Together AI",
|
|
155
|
+
family: "openai-compatible",
|
|
156
|
+
defaultModel: "meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
|
157
|
+
baseUrl: "https://api.together.xyz/v1",
|
|
158
|
+
auth: "bearer",
|
|
159
|
+
capabilities: TEXT,
|
|
160
|
+
models: models("meta-llama/Llama-3.3-70B-Instruct-Turbo", "mistralai/Mixtral-8x7B-Instruct-v0.1"),
|
|
161
|
+
},
|
|
162
|
+
fireworks: {
|
|
163
|
+
id: "fireworks",
|
|
164
|
+
label: "Fireworks AI",
|
|
165
|
+
family: "openai-compatible",
|
|
166
|
+
defaultModel: "accounts/fireworks/models/llama-v3p3-70b-instruct",
|
|
167
|
+
baseUrl: "https://api.fireworks.ai/inference/v1",
|
|
168
|
+
auth: "bearer",
|
|
169
|
+
capabilities: TEXT,
|
|
170
|
+
models: models("accounts/fireworks/models/llama-v3p3-70b-instruct"),
|
|
171
|
+
},
|
|
172
|
+
perplexity: {
|
|
173
|
+
id: "perplexity",
|
|
174
|
+
label: "Perplexity",
|
|
175
|
+
family: "openai-compatible",
|
|
176
|
+
defaultModel: "sonar",
|
|
177
|
+
baseUrl: "https://api.perplexity.ai",
|
|
178
|
+
auth: "bearer",
|
|
179
|
+
capabilities: TEXT,
|
|
180
|
+
models: models("sonar", "sonar-pro", "sonar-reasoning"),
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export const ALL_PROVIDER_IDS = Object.keys(PROVIDER_CATALOG) as AiProviderId[];
|
|
185
|
+
|
|
186
|
+
export function getProviderSpec(id: AiProviderId): ProviderSpec {
|
|
187
|
+
return PROVIDER_CATALOG[id];
|
|
188
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AIProvider, AiProviderId, ProviderFactory } from "../ports/ai";
|
|
2
|
+
import type { InMemoryProviderRegistry } from "./registry";
|
|
3
|
+
import { ProviderError } from "../errors/app-error";
|
|
4
|
+
|
|
5
|
+
/** Creates provider strategies from the registry (Factory pattern). */
|
|
6
|
+
export class RegistryProviderFactory implements ProviderFactory {
|
|
7
|
+
constructor(private readonly registry: InMemoryProviderRegistry) {}
|
|
8
|
+
|
|
9
|
+
create(id: AiProviderId): AIProvider {
|
|
10
|
+
const registration = this.registry.get(id);
|
|
11
|
+
if (!registration) throw new ProviderError(`Unknown AI provider: ${id}`, { details: { provider: id } });
|
|
12
|
+
return registration.create();
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ProviderError } from "../../errors/app-error";
|
|
2
|
+
|
|
3
|
+
/** Injected fetch for testability; defaults to the platform global. */
|
|
4
|
+
export type FetchLike = typeof fetch;
|
|
5
|
+
|
|
6
|
+
export function resolveFetch(custom?: FetchLike): FetchLike {
|
|
7
|
+
const f = custom ?? (globalThis.fetch as FetchLike | undefined);
|
|
8
|
+
if (!f) throw new ProviderError("No fetch implementation available in this runtime.");
|
|
9
|
+
return f;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Yield the payload of each `data:` line from an SSE response body. */
|
|
13
|
+
export async function* readSseData(body: ReadableStream<Uint8Array>): AsyncGenerator<string> {
|
|
14
|
+
const reader = body.getReader();
|
|
15
|
+
const decoder = new TextDecoder("utf-8");
|
|
16
|
+
let buffer = "";
|
|
17
|
+
for (;;) {
|
|
18
|
+
const { value, done } = await reader.read();
|
|
19
|
+
if (done) break;
|
|
20
|
+
buffer += decoder.decode(value, { stream: true });
|
|
21
|
+
const lines = buffer.split("\n");
|
|
22
|
+
buffer = lines.pop() ?? "";
|
|
23
|
+
for (const line of lines) {
|
|
24
|
+
const trimmed = line.trim();
|
|
25
|
+
if (trimmed.startsWith("data:")) {
|
|
26
|
+
yield trimmed.slice(5).trim();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const tail = buffer.trim();
|
|
31
|
+
if (tail.startsWith("data:")) yield tail.slice(5).trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Yield each complete JSON line from an NDJSON response body (e.g. Ollama). */
|
|
35
|
+
export async function* readJsonLines(body: ReadableStream<Uint8Array>): AsyncGenerator<string> {
|
|
36
|
+
const reader = body.getReader();
|
|
37
|
+
const decoder = new TextDecoder("utf-8");
|
|
38
|
+
let buffer = "";
|
|
39
|
+
for (;;) {
|
|
40
|
+
const { value, done } = await reader.read();
|
|
41
|
+
if (done) break;
|
|
42
|
+
buffer += decoder.decode(value, { stream: true });
|
|
43
|
+
const lines = buffer.split("\n");
|
|
44
|
+
buffer = lines.pop() ?? "";
|
|
45
|
+
for (const line of lines) {
|
|
46
|
+
const trimmed = line.trim();
|
|
47
|
+
if (trimmed) yield trimmed;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const tail = buffer.trim();
|
|
51
|
+
if (tail) yield tail;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function ensureOk(response: Response, providerLabel: string): Promise<Response> {
|
|
55
|
+
if (response.ok && response.body) return response;
|
|
56
|
+
const detail = await response.text().catch(() => "");
|
|
57
|
+
throw new ProviderError(`${providerLabel} request failed: ${response.status} ${response.statusText} ${detail}`, {
|
|
58
|
+
details: { status: response.status },
|
|
59
|
+
});
|
|
60
|
+
}
|
package/src/ai/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Shared AI system. Registry-driven (Strategy + Factory + Registry), consumed
|
|
2
|
+
// by the frontend, CLI and MCP. Adding a provider = a catalog entry.
|
|
3
|
+
export {
|
|
4
|
+
PROVIDER_CATALOG,
|
|
5
|
+
ALL_PROVIDER_IDS,
|
|
6
|
+
getProviderSpec,
|
|
7
|
+
} from "./catalog";
|
|
8
|
+
export type { ProviderFamily, ProviderSpec, AuthStyle } from "./catalog";
|
|
9
|
+
export { InMemoryProviderRegistry } from "./registry";
|
|
10
|
+
export { RegistryProviderFactory } from "./factory";
|
|
11
|
+
export { CatalogModelRegistry } from "./model-registry";
|
|
12
|
+
export {
|
|
13
|
+
createDefaultRegistry,
|
|
14
|
+
createDefaultFactory,
|
|
15
|
+
createModelRegistry,
|
|
16
|
+
} from "./bootstrap";
|
|
17
|
+
export type { FetchLike } from "./http/streaming";
|
|
18
|
+
export { OpenAiCompatibleProvider } from "./providers/openai-compatible-provider";
|
|
19
|
+
export { AnthropicProvider } from "./providers/anthropic-provider";
|
|
20
|
+
export { GeminiProvider } from "./providers/gemini-provider";
|
|
21
|
+
export { OllamaProvider } from "./providers/ollama-provider";
|
|
22
|
+
export { CohereProvider } from "./providers/cohere-provider";
|
|
23
|
+
export {
|
|
24
|
+
legacyProviderToCatalogId,
|
|
25
|
+
parseLegacyProviderAndModel,
|
|
26
|
+
} from "./legacy-adapter";
|
|
27
|
+
export type { ParsedLegacyProvider } from "./legacy-adapter";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { AiProviderId } from "../ports/ai";
|
|
2
|
+
import { PROVIDER_CATALOG } from "./catalog";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Bridges the legacy frontend/CLI provider ids ("claude", and the
|
|
6
|
+
* "provider:model" string form) to the shared catalog. Lets existing consumers
|
|
7
|
+
* adopt tools/src/ai without changing their stored config strings.
|
|
8
|
+
*/
|
|
9
|
+
const LEGACY_ALIASES: Readonly<Record<string, AiProviderId>> = {
|
|
10
|
+
openai: "openai",
|
|
11
|
+
claude: "anthropic",
|
|
12
|
+
anthropic: "anthropic",
|
|
13
|
+
gemini: "gemini",
|
|
14
|
+
ollama: "ollama",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function legacyProviderToCatalogId(legacy: string | undefined): AiProviderId {
|
|
18
|
+
const key = (legacy ?? "").trim().toLowerCase();
|
|
19
|
+
return LEGACY_ALIASES[key] ?? "openai";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ParsedLegacyProvider {
|
|
23
|
+
readonly providerId: AiProviderId;
|
|
24
|
+
readonly model: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Parse a legacy "provider:model" string (e.g. "claude:claude-3-5-sonnet"). */
|
|
28
|
+
export function parseLegacyProviderAndModel(value: string | undefined): ParsedLegacyProvider {
|
|
29
|
+
const [rawProvider, rawModel] = (value ?? "").split(":");
|
|
30
|
+
const providerId = legacyProviderToCatalogId(rawProvider);
|
|
31
|
+
const model = (rawModel ?? "").trim() || PROVIDER_CATALOG[providerId].defaultModel;
|
|
32
|
+
return { providerId, model };
|
|
33
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AiProviderId, ModelDescriptor, ModelRegistry } from "../ports/ai";
|
|
2
|
+
import { PROVIDER_CATALOG } from "./catalog";
|
|
3
|
+
|
|
4
|
+
/** Model registry backed by the static provider catalog. */
|
|
5
|
+
export class CatalogModelRegistry implements ModelRegistry {
|
|
6
|
+
list(providerId: AiProviderId): readonly ModelDescriptor[] {
|
|
7
|
+
return PROVIDER_CATALOG[providerId]?.models ?? [];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
defaultModel(providerId: AiProviderId): string | undefined {
|
|
11
|
+
return PROVIDER_CATALOG[providerId]?.defaultModel;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AIProvider,
|
|
3
|
+
AiMessage,
|
|
4
|
+
AiProviderId,
|
|
5
|
+
GenerateRequest,
|
|
6
|
+
GenerateResponse,
|
|
7
|
+
ProviderCapabilities,
|
|
8
|
+
ProviderConfig,
|
|
9
|
+
StreamResponse,
|
|
10
|
+
} from "../../ports/ai";
|
|
11
|
+
import type { ProviderSpec } from "../catalog";
|
|
12
|
+
import { type FetchLike, ensureOk, readSseData, resolveFetch } from "../http/streaming";
|
|
13
|
+
import { buildAuthHeaders, collect, resolveBaseUrl } from "./shared";
|
|
14
|
+
|
|
15
|
+
/** Anthropic Messages API adapter (content_block_delta streaming). */
|
|
16
|
+
export class AnthropicProvider implements AIProvider {
|
|
17
|
+
readonly id: AiProviderId;
|
|
18
|
+
readonly capabilities: ProviderCapabilities;
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
private readonly spec: ProviderSpec,
|
|
22
|
+
private readonly fetchImpl?: FetchLike,
|
|
23
|
+
) {
|
|
24
|
+
this.id = spec.id;
|
|
25
|
+
this.capabilities = spec.capabilities;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private mapMessage(msg: AiMessage): unknown {
|
|
29
|
+
const images = (msg.attachments ?? []).filter((a) => a.kind === "image");
|
|
30
|
+
if (images.length === 0) return { role: msg.role, content: msg.content };
|
|
31
|
+
const content: unknown[] = [];
|
|
32
|
+
if (msg.content) content.push({ type: "text", text: msg.content });
|
|
33
|
+
for (const img of images) {
|
|
34
|
+
content.push({
|
|
35
|
+
type: "image",
|
|
36
|
+
source: { type: "base64", media_type: img.mimeType, data: img.data },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return { role: msg.role, content };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async *stream(request: GenerateRequest, config: ProviderConfig): AsyncGenerator<string> {
|
|
43
|
+
const fetchImpl = resolveFetch(this.fetchImpl);
|
|
44
|
+
const base = resolveBaseUrl(this.spec, config);
|
|
45
|
+
const chatMessages = request.messages.filter((m) => m.role !== "system").map((m) => this.mapMessage(m));
|
|
46
|
+
const joinedSystem = request.messages
|
|
47
|
+
.filter((m) => m.role === "system")
|
|
48
|
+
.map((m) => m.content)
|
|
49
|
+
.join("\n");
|
|
50
|
+
const system = request.system ?? (joinedSystem || undefined);
|
|
51
|
+
|
|
52
|
+
const response = await fetchImpl(`${base}/messages`, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: buildAuthHeaders(this.spec, config),
|
|
55
|
+
body: JSON.stringify({
|
|
56
|
+
model: config.model || this.spec.defaultModel,
|
|
57
|
+
max_tokens: request.maxTokens ?? 4000,
|
|
58
|
+
temperature: request.temperature,
|
|
59
|
+
system,
|
|
60
|
+
messages: chatMessages,
|
|
61
|
+
stream: true,
|
|
62
|
+
}),
|
|
63
|
+
signal: request.signal,
|
|
64
|
+
});
|
|
65
|
+
const ok = await ensureOk(response, this.spec.label);
|
|
66
|
+
for await (const data of readSseData(ok.body as ReadableStream<Uint8Array>)) {
|
|
67
|
+
if (!data || data === "[DONE]") continue;
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(data) as { type?: string; delta?: { text?: string } };
|
|
70
|
+
if (parsed.type === "content_block_delta" && parsed.delta?.text) {
|
|
71
|
+
yield parsed.delta.text;
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
/* ignore partial frames */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async generate(request: GenerateRequest, config: ProviderConfig): Promise<GenerateResponse> {
|
|
80
|
+
const text = await collect(this.stream(request, config) as StreamResponse);
|
|
81
|
+
return { text, model: config.model || this.spec.defaultModel };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AIProvider,
|
|
3
|
+
AiProviderId,
|
|
4
|
+
GenerateRequest,
|
|
5
|
+
GenerateResponse,
|
|
6
|
+
ProviderCapabilities,
|
|
7
|
+
ProviderConfig,
|
|
8
|
+
StreamResponse,
|
|
9
|
+
} from "../../ports/ai";
|
|
10
|
+
import type { ProviderSpec } from "../catalog";
|
|
11
|
+
import { type FetchLike, ensureOk, readSseData, resolveFetch } from "../http/streaming";
|
|
12
|
+
import { buildAuthHeaders, collect, resolveBaseUrl } from "./shared";
|
|
13
|
+
|
|
14
|
+
/** Cohere Chat v2 adapter (SSE content-delta streaming). */
|
|
15
|
+
export class CohereProvider implements AIProvider {
|
|
16
|
+
readonly id: AiProviderId;
|
|
17
|
+
readonly capabilities: ProviderCapabilities;
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly spec: ProviderSpec,
|
|
21
|
+
private readonly fetchImpl?: FetchLike,
|
|
22
|
+
) {
|
|
23
|
+
this.id = spec.id;
|
|
24
|
+
this.capabilities = spec.capabilities;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async *stream(request: GenerateRequest, config: ProviderConfig): AsyncGenerator<string> {
|
|
28
|
+
const fetchImpl = resolveFetch(this.fetchImpl);
|
|
29
|
+
const base = resolveBaseUrl(this.spec, config);
|
|
30
|
+
const messages: Array<{ role: string; content: string }> = [];
|
|
31
|
+
if (request.system) messages.push({ role: "system", content: request.system });
|
|
32
|
+
for (const m of request.messages) messages.push({ role: m.role, content: m.content });
|
|
33
|
+
|
|
34
|
+
const response = await fetchImpl(`${base}/chat`, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: buildAuthHeaders(this.spec, config),
|
|
37
|
+
body: JSON.stringify({
|
|
38
|
+
model: config.model || this.spec.defaultModel,
|
|
39
|
+
messages,
|
|
40
|
+
temperature: request.temperature,
|
|
41
|
+
max_tokens: request.maxTokens,
|
|
42
|
+
stream: true,
|
|
43
|
+
}),
|
|
44
|
+
signal: request.signal,
|
|
45
|
+
});
|
|
46
|
+
const ok = await ensureOk(response, this.spec.label);
|
|
47
|
+
for await (const data of readSseData(ok.body as ReadableStream<Uint8Array>)) {
|
|
48
|
+
if (!data) continue;
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(data) as {
|
|
51
|
+
type?: string;
|
|
52
|
+
delta?: { message?: { content?: { text?: string } } };
|
|
53
|
+
};
|
|
54
|
+
const text = parsed.delta?.message?.content?.text;
|
|
55
|
+
if (parsed.type === "content-delta" && text) yield text;
|
|
56
|
+
} catch {
|
|
57
|
+
/* ignore partial frames */
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async generate(request: GenerateRequest, config: ProviderConfig): Promise<GenerateResponse> {
|
|
63
|
+
const text = await collect(this.stream(request, config) as StreamResponse);
|
|
64
|
+
return { text, model: config.model || this.spec.defaultModel };
|
|
65
|
+
}
|
|
66
|
+
}
|