pretticlaw 0.1.2 → 0.1.3
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/.env.example +5 -0
- package/.github/workflows/main.yml +47 -0
- package/Dockerfile +19 -0
- package/dist/cli/commands.js +2 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/providers/gemini-provider.d.ts +20 -0
- package/dist/providers/gemini-provider.js +174 -0
- package/dist/providers/registry.js +5 -1
- package/docker-compose.yaml +20 -0
- package/entrypoint.sh +50 -0
- package/line_count.sh +10 -0
- package/package.json +1 -1
- package/src/cli/commands.ts +2 -0
- package/src/dashboard/index.html +1 -1
- package/src/providers/gemini-provider.ts +216 -0
- package/src/providers/registry.ts +6 -2
- package/test/providers.test.ts +12 -1
package/.env.example
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
name: Build and Push Docker Image
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
release:
|
|
8
|
+
types: [published]
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
build:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
|
|
14
|
+
permissions:
|
|
15
|
+
contents: read
|
|
16
|
+
packages: write
|
|
17
|
+
|
|
18
|
+
steps:
|
|
19
|
+
- name: Checkout repo
|
|
20
|
+
uses: actions/checkout@v4
|
|
21
|
+
|
|
22
|
+
- name: Set up Docker Buildx
|
|
23
|
+
uses: docker/setup-buildx-action@v3
|
|
24
|
+
|
|
25
|
+
- name: Login to Docker Hub
|
|
26
|
+
uses: docker/login-action@v3
|
|
27
|
+
with:
|
|
28
|
+
username: ${{ secrets.DOCKER_USERNAME }}
|
|
29
|
+
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
30
|
+
|
|
31
|
+
- name: Extract metadata (tags)
|
|
32
|
+
id: meta
|
|
33
|
+
uses: docker/metadata-action@v5
|
|
34
|
+
with:
|
|
35
|
+
images: amitdey13/pretticlaw
|
|
36
|
+
tags: |
|
|
37
|
+
type=raw,value=latest
|
|
38
|
+
type=ref,event=branch
|
|
39
|
+
type=ref,event=tag
|
|
40
|
+
|
|
41
|
+
- name: Build and push
|
|
42
|
+
uses: docker/build-push-action@v5
|
|
43
|
+
with:
|
|
44
|
+
context: .
|
|
45
|
+
push: true
|
|
46
|
+
platforms: linux/amd64,linux/arm64
|
|
47
|
+
tags: ${{ steps.meta.outputs.tags }}
|
package/Dockerfile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
FROM node:20-alpine
|
|
2
|
+
|
|
3
|
+
# Create app directory
|
|
4
|
+
WORKDIR /app
|
|
5
|
+
|
|
6
|
+
# Install pretticlaw globally
|
|
7
|
+
RUN npm install -g pretticlaw
|
|
8
|
+
|
|
9
|
+
# Create pretticlaw home
|
|
10
|
+
RUN mkdir -p /root/.pretticlaw
|
|
11
|
+
|
|
12
|
+
# Copy entrypoint
|
|
13
|
+
COPY entrypoint.sh /entrypoint.sh
|
|
14
|
+
RUN chmod +x /entrypoint.sh
|
|
15
|
+
|
|
16
|
+
EXPOSE 18790
|
|
17
|
+
EXPOSE 6767
|
|
18
|
+
|
|
19
|
+
ENTRYPOINT ["/entrypoint.sh"]
|
package/dist/cli/commands.js
CHANGED
|
@@ -54,6 +54,7 @@ const MODEL_CHOICES = {
|
|
|
54
54
|
"deepseek/deepseek-reasoner",
|
|
55
55
|
],
|
|
56
56
|
gemini: [
|
|
57
|
+
"gemini/gemini-3-flash-preview",
|
|
57
58
|
"gemini/gemini-2.5-pro",
|
|
58
59
|
"gemini/gemini-2.5-flash",
|
|
59
60
|
],
|
|
@@ -128,6 +129,7 @@ export function buildProgram() {
|
|
|
128
129
|
{ title: "OpenAI", value: "openai" },
|
|
129
130
|
{ title: "DeepSeek", value: "deepseek" },
|
|
130
131
|
{ title: "Groq", value: "groq" },
|
|
132
|
+
{ title: "Gemini", value: "gemini" },
|
|
131
133
|
{ title: "Moonshot", value: "moonshot" },
|
|
132
134
|
{ title: "MiniMax", value: "minimax" },
|
|
133
135
|
{ title: "DashScope", value: "dashscope" },
|
|
@@ -989,7 +989,7 @@
|
|
|
989
989
|
anthropic: ["anthropic/claude-opus-4-5","anthropic/claude-sonnet-4"],
|
|
990
990
|
openai: ["gpt-5.2","gpt-5.2-pro","gpt-5.3-codex","gpt-5.2-codex","gpt-5.1","gpt-5.1-codex","gpt-5 mini","gpt-5 nano","gpt-4.1","gpt-4.1 mini","gpt-4.1 nano","gpt-4o","gpt-4o mini"],
|
|
991
991
|
deepseek: ["deepseek/deepseek-chat","deepseek/deepseek-reasoner"],
|
|
992
|
-
gemini: ["gemini/gemini-2.5-pro","gemini/gemini-2.5-flash"],
|
|
992
|
+
gemini: ["gemini/gemini-3-flash-preview","gemini/gemini-2.5-pro","gemini/gemini-2.5-flash"],
|
|
993
993
|
moonshot: ["moonshot/kimi-k2.5"],
|
|
994
994
|
minimax: ["minimax/MiniMax-M2.1"],
|
|
995
995
|
dashscope: ["dashscope/qwen-max"],
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { LLMProvider, LLMResponse } from "./base.js";
|
|
2
|
+
export declare class GeminiProvider implements LLMProvider {
|
|
3
|
+
private readonly apiKey;
|
|
4
|
+
private readonly apiBase;
|
|
5
|
+
private readonly defaultModel;
|
|
6
|
+
private static readonly DEFAULT_BASE;
|
|
7
|
+
constructor(apiKey: string, apiBase: string | null, defaultModel: string);
|
|
8
|
+
getDefaultModel(): string;
|
|
9
|
+
resolveModel(model: string): string;
|
|
10
|
+
private asText;
|
|
11
|
+
private parseToolArgs;
|
|
12
|
+
private toGemini;
|
|
13
|
+
chat(input: {
|
|
14
|
+
messages: Array<Record<string, unknown>>;
|
|
15
|
+
tools?: Array<Record<string, unknown>>;
|
|
16
|
+
model?: string;
|
|
17
|
+
maxTokens?: number;
|
|
18
|
+
temperature?: number;
|
|
19
|
+
}): Promise<LLMResponse>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { nanoid } from "nanoid";
|
|
2
|
+
export class GeminiProvider {
|
|
3
|
+
apiKey;
|
|
4
|
+
apiBase;
|
|
5
|
+
defaultModel;
|
|
6
|
+
static DEFAULT_BASE = "https://generativelanguage.googleapis.com/v1beta";
|
|
7
|
+
constructor(apiKey, apiBase, defaultModel) {
|
|
8
|
+
this.apiKey = apiKey;
|
|
9
|
+
this.apiBase = apiBase;
|
|
10
|
+
this.defaultModel = defaultModel;
|
|
11
|
+
}
|
|
12
|
+
getDefaultModel() {
|
|
13
|
+
return this.defaultModel;
|
|
14
|
+
}
|
|
15
|
+
resolveModel(model) {
|
|
16
|
+
let out = model.trim();
|
|
17
|
+
if (out.includes("/")) {
|
|
18
|
+
const [prefix, rest] = out.split("/", 2);
|
|
19
|
+
const norm = prefix.toLowerCase();
|
|
20
|
+
if (norm === "gemini" || norm === "google")
|
|
21
|
+
out = rest;
|
|
22
|
+
}
|
|
23
|
+
return out.startsWith("models/") ? out.slice("models/".length) : out;
|
|
24
|
+
}
|
|
25
|
+
asText(value) {
|
|
26
|
+
if (typeof value === "string")
|
|
27
|
+
return value;
|
|
28
|
+
if (value == null)
|
|
29
|
+
return "";
|
|
30
|
+
try {
|
|
31
|
+
return JSON.stringify(value);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return String(value);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
parseToolArgs(value) {
|
|
38
|
+
if (value && typeof value === "object" && !Array.isArray(value))
|
|
39
|
+
return value;
|
|
40
|
+
if (typeof value === "string") {
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(value);
|
|
43
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
44
|
+
return parsed;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// ignore invalid json and return empty args
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
toGemini(input) {
|
|
53
|
+
const systemTexts = [];
|
|
54
|
+
const contents = [];
|
|
55
|
+
for (const msg of input.messages) {
|
|
56
|
+
const role = String(msg.role ?? "");
|
|
57
|
+
if (role === "system") {
|
|
58
|
+
const txt = this.asText(msg.content).trim();
|
|
59
|
+
if (txt)
|
|
60
|
+
systemTexts.push(txt);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (role === "user") {
|
|
64
|
+
const txt = this.asText(msg.content).trim();
|
|
65
|
+
if (!txt)
|
|
66
|
+
continue;
|
|
67
|
+
contents.push({ role: "user", parts: [{ text: txt }] });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (role === "assistant" || role === "model") {
|
|
71
|
+
const parts = [];
|
|
72
|
+
const txt = this.asText(msg.content).trim();
|
|
73
|
+
if (txt)
|
|
74
|
+
parts.push({ text: txt });
|
|
75
|
+
const toolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
|
|
76
|
+
for (const tc of toolCalls) {
|
|
77
|
+
const fn = tc.function;
|
|
78
|
+
if (!fn?.name)
|
|
79
|
+
continue;
|
|
80
|
+
parts.push({ functionCall: { name: fn.name, args: this.parseToolArgs(fn.arguments) } });
|
|
81
|
+
}
|
|
82
|
+
if (parts.length)
|
|
83
|
+
contents.push({ role: "model", parts });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (role === "tool") {
|
|
87
|
+
const name = typeof msg.name === "string" ? msg.name : "";
|
|
88
|
+
if (!name)
|
|
89
|
+
continue;
|
|
90
|
+
const contentText = this.asText(msg.content);
|
|
91
|
+
contents.push({
|
|
92
|
+
role: "user",
|
|
93
|
+
parts: [{ functionResponse: { name, response: { result: contentText } } }],
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!contents.length) {
|
|
98
|
+
contents.push({ role: "user", parts: [{ text: "Hello" }] });
|
|
99
|
+
}
|
|
100
|
+
const out = {
|
|
101
|
+
contents,
|
|
102
|
+
generationConfig: {
|
|
103
|
+
temperature: input.temperature ?? 0.1,
|
|
104
|
+
maxOutputTokens: Math.max(1, input.maxTokens ?? 4096),
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
if (systemTexts.length) {
|
|
108
|
+
out.systemInstruction = {
|
|
109
|
+
parts: [{ text: systemTexts.join("\n\n") }],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const tools = (input.tools ?? [])
|
|
113
|
+
.map((t) => t)
|
|
114
|
+
.filter((t) => t.type === "function" && t.function?.name)
|
|
115
|
+
.map((t) => ({
|
|
116
|
+
name: t.function.name,
|
|
117
|
+
description: t.function?.description ?? "",
|
|
118
|
+
parameters: t.function?.parameters ?? { type: "object", properties: {} },
|
|
119
|
+
}));
|
|
120
|
+
if (tools.length) {
|
|
121
|
+
out.tools = [{ functionDeclarations: tools }];
|
|
122
|
+
out.toolConfig = { functionCallingConfig: { mode: "AUTO" } };
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
async chat(input) {
|
|
127
|
+
try {
|
|
128
|
+
const model = this.resolveModel(input.model ?? this.defaultModel);
|
|
129
|
+
const base = (this.apiBase ?? GeminiProvider.DEFAULT_BASE).replace(/\/$/, "");
|
|
130
|
+
const url = `${base}/models/${encodeURIComponent(model)}:generateContent`;
|
|
131
|
+
const body = this.toGemini(input);
|
|
132
|
+
const res = await fetch(url, {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: {
|
|
135
|
+
"Content-Type": "application/json",
|
|
136
|
+
"x-goog-api-key": this.apiKey,
|
|
137
|
+
},
|
|
138
|
+
body: JSON.stringify(body),
|
|
139
|
+
});
|
|
140
|
+
const json = await res.json();
|
|
141
|
+
if (!res.ok) {
|
|
142
|
+
const msg = json?.error?.message ?? JSON.stringify(json);
|
|
143
|
+
return { content: `Error calling LLM: ${msg}`, toolCalls: [], finishReason: "error", usage: {}, reasoningContent: null };
|
|
144
|
+
}
|
|
145
|
+
const candidate = json?.candidates?.[0];
|
|
146
|
+
const parts = candidate?.content?.parts ?? [];
|
|
147
|
+
const textParts = parts.filter((p) => typeof p?.text === "string").map((p) => String(p.text));
|
|
148
|
+
const toolCalls = parts
|
|
149
|
+
.filter((p) => p?.functionCall?.name)
|
|
150
|
+
.map((p) => ({
|
|
151
|
+
id: nanoid(9),
|
|
152
|
+
name: String(p.functionCall.name),
|
|
153
|
+
arguments: this.parseToolArgs(p.functionCall.args),
|
|
154
|
+
}));
|
|
155
|
+
const usage = {};
|
|
156
|
+
if (json?.usageMetadata) {
|
|
157
|
+
usage.prompt_tokens = Number(json.usageMetadata.promptTokenCount ?? 0);
|
|
158
|
+
usage.completion_tokens = Number(json.usageMetadata.candidatesTokenCount ?? 0);
|
|
159
|
+
usage.total_tokens = Number(json.usageMetadata.totalTokenCount ?? 0);
|
|
160
|
+
}
|
|
161
|
+
const finish = String(candidate?.finishReason ?? "STOP").toLowerCase();
|
|
162
|
+
return {
|
|
163
|
+
content: textParts.length ? textParts.join("\n") : null,
|
|
164
|
+
toolCalls,
|
|
165
|
+
finishReason: finish,
|
|
166
|
+
usage,
|
|
167
|
+
reasoningContent: null,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
return { content: `Error calling LLM: ${String(err)}`, toolCalls: [], finishReason: "error", usage: {}, reasoningContent: null };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { PROVIDERS, getApiBase, getProvider, getProviderName } from "../config/schema.js";
|
|
2
2
|
import { CustomProvider } from "./custom-provider.js";
|
|
3
|
+
import { GeminiProvider } from "./gemini-provider.js";
|
|
3
4
|
import { LiteLLMProvider } from "./litellm-provider.js";
|
|
4
5
|
export function findByModel(model) {
|
|
5
6
|
const lower = model.toLowerCase();
|
|
@@ -28,7 +29,7 @@ export function makeProvider(config) {
|
|
|
28
29
|
const providerName = getProviderName(config, model);
|
|
29
30
|
const p = getProvider(config, model);
|
|
30
31
|
const oauthProviders = new Set(["openai_codex", "github_copilot"]);
|
|
31
|
-
const unsupportedInTs = new Set(["anthropic"
|
|
32
|
+
const unsupportedInTs = new Set(["anthropic"]);
|
|
32
33
|
if (!providerName) {
|
|
33
34
|
throw new Error("No provider could be resolved from config. Run `pretticlaw onboard` and set provider/model/API key.");
|
|
34
35
|
}
|
|
@@ -41,5 +42,8 @@ export function makeProvider(config) {
|
|
|
41
42
|
if (providerName === "custom") {
|
|
42
43
|
return new CustomProvider(p?.apiKey || "no-key", getApiBase(config, model) || "http://localhost:8000/v1", model);
|
|
43
44
|
}
|
|
45
|
+
if (providerName === "gemini") {
|
|
46
|
+
return new GeminiProvider(p?.apiKey || "", getApiBase(config, model), model);
|
|
47
|
+
}
|
|
44
48
|
return new LiteLLMProvider(p?.apiKey ?? null, getApiBase(config, model), model, providerName);
|
|
45
49
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
services:
|
|
2
|
+
pretticlaw:
|
|
3
|
+
build: .
|
|
4
|
+
container_name: pretticlaw
|
|
5
|
+
ports:
|
|
6
|
+
- "18790:18790"
|
|
7
|
+
- "6767:6767"
|
|
8
|
+
environment:
|
|
9
|
+
PROVIDER: ${PROVIDER}
|
|
10
|
+
MODEL: ${MODEL}
|
|
11
|
+
API_KEY: ${API_KEY}
|
|
12
|
+
API_BASE: ${API_BASE}
|
|
13
|
+
TEMPERATURE: ${TEMPERATURE}
|
|
14
|
+
MAX_TOKENS: ${MAX_TOKENS}
|
|
15
|
+
volumes:
|
|
16
|
+
- pretticlaw_data:/root/.pretticlaw
|
|
17
|
+
restart: unless-stopped
|
|
18
|
+
|
|
19
|
+
volumes:
|
|
20
|
+
pretticlaw_data:
|
package/entrypoint.sh
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
CONFIG_DIR="/root/.pretticlaw"
|
|
5
|
+
CONFIG_FILE="$CONFIG_DIR/config.json"
|
|
6
|
+
|
|
7
|
+
mkdir -p $CONFIG_DIR/workspace
|
|
8
|
+
|
|
9
|
+
PROVIDER=${PROVIDER:-openai}
|
|
10
|
+
MODEL=${MODEL:-gpt-4.1-mini}
|
|
11
|
+
API_KEY=${API_KEY}
|
|
12
|
+
API_BASE=${API_BASE:-null}
|
|
13
|
+
TEMPERATURE=${TEMPERATURE:-0.1}
|
|
14
|
+
MAX_TOKENS=${MAX_TOKENS:-8192}
|
|
15
|
+
|
|
16
|
+
if [ -z "$API_KEY" ]; then
|
|
17
|
+
echo "ERROR: API_KEY is required"
|
|
18
|
+
exit 1
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
if [ ! -f "$CONFIG_FILE" ]; then
|
|
22
|
+
echo "Generating config for provider: $PROVIDER"
|
|
23
|
+
|
|
24
|
+
cat <<EOF > $CONFIG_FILE
|
|
25
|
+
{
|
|
26
|
+
"agents": {
|
|
27
|
+
"defaults": {
|
|
28
|
+
"workspace": "/root/.pretticlaw/workspace",
|
|
29
|
+
"model": "$MODEL",
|
|
30
|
+
"provider": "$PROVIDER",
|
|
31
|
+
"maxTokens": $MAX_TOKENS,
|
|
32
|
+
"temperature": $TEMPERATURE
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"providers": {
|
|
36
|
+
"$PROVIDER": {
|
|
37
|
+
"apiKey": "$API_KEY",
|
|
38
|
+
"apiBase": $API_BASE
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"gateway": {
|
|
42
|
+
"host": "0.0.0.0",
|
|
43
|
+
"port": 18790
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
EOF
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
echo "Starting Pretticlaw Gateway..."
|
|
50
|
+
pretticlaw gateway
|
package/line_count.sh
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Count all lines in all files under src/ (recursively)
|
|
3
|
+
cd "$(dirname "$0")" || exit 1
|
|
4
|
+
|
|
5
|
+
echo "pretticlaw src/ line count"
|
|
6
|
+
echo "================================"
|
|
7
|
+
echo ""
|
|
8
|
+
|
|
9
|
+
total=$(find src -type f -exec cat {} + | wc -l)
|
|
10
|
+
echo " Total lines in src/: $total"
|
package/package.json
CHANGED
package/src/cli/commands.ts
CHANGED
|
@@ -64,6 +64,7 @@ openai: [
|
|
|
64
64
|
],
|
|
65
65
|
|
|
66
66
|
gemini: [
|
|
67
|
+
"gemini/gemini-3-flash-preview",
|
|
67
68
|
"gemini/gemini-2.5-pro",
|
|
68
69
|
"gemini/gemini-2.5-flash",
|
|
69
70
|
],
|
|
@@ -141,6 +142,7 @@ export function buildProgram(): Command {
|
|
|
141
142
|
{ title: "OpenAI", value: "openai" },
|
|
142
143
|
{ title: "DeepSeek", value: "deepseek" },
|
|
143
144
|
{ title: "Groq", value: "groq" },
|
|
145
|
+
{ title: "Gemini", value: "gemini" },
|
|
144
146
|
{ title: "Moonshot", value: "moonshot" },
|
|
145
147
|
{ title: "MiniMax", value: "minimax" },
|
|
146
148
|
{ title: "DashScope", value: "dashscope" },
|
package/src/dashboard/index.html
CHANGED
|
@@ -989,7 +989,7 @@
|
|
|
989
989
|
anthropic: ["anthropic/claude-opus-4-5","anthropic/claude-sonnet-4"],
|
|
990
990
|
openai: ["gpt-5.2","gpt-5.2-pro","gpt-5.3-codex","gpt-5.2-codex","gpt-5.1","gpt-5.1-codex","gpt-5 mini","gpt-5 nano","gpt-4.1","gpt-4.1 mini","gpt-4.1 nano","gpt-4o","gpt-4o mini"],
|
|
991
991
|
deepseek: ["deepseek/deepseek-chat","deepseek/deepseek-reasoner"],
|
|
992
|
-
gemini: ["gemini/gemini-2.5-pro","gemini/gemini-2.5-flash"],
|
|
992
|
+
gemini: ["gemini/gemini-3-flash-preview","gemini/gemini-2.5-pro","gemini/gemini-2.5-flash"],
|
|
993
993
|
moonshot: ["moonshot/kimi-k2.5"],
|
|
994
994
|
minimax: ["minimax/MiniMax-M2.1"],
|
|
995
995
|
dashscope: ["dashscope/qwen-max"],
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { nanoid } from "nanoid";
|
|
2
|
+
import type { LLMProvider, LLMResponse, ToolCallRequest } from "./base.js";
|
|
3
|
+
|
|
4
|
+
type OpenAITool = {
|
|
5
|
+
type?: string;
|
|
6
|
+
function?: {
|
|
7
|
+
name?: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
parameters?: Record<string, unknown>;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type GeminiPart =
|
|
14
|
+
| { text: string }
|
|
15
|
+
| { functionCall: { name: string; args?: Record<string, unknown> } }
|
|
16
|
+
| { functionResponse: { name: string; response: Record<string, unknown> } };
|
|
17
|
+
|
|
18
|
+
type GeminiContent = {
|
|
19
|
+
role: "user" | "model";
|
|
20
|
+
parts: GeminiPart[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export class GeminiProvider implements LLMProvider {
|
|
24
|
+
private static readonly DEFAULT_BASE = "https://generativelanguage.googleapis.com/v1beta";
|
|
25
|
+
|
|
26
|
+
constructor(
|
|
27
|
+
private readonly apiKey: string,
|
|
28
|
+
private readonly apiBase: string | null,
|
|
29
|
+
private readonly defaultModel: string,
|
|
30
|
+
) {}
|
|
31
|
+
|
|
32
|
+
getDefaultModel(): string {
|
|
33
|
+
return this.defaultModel;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
resolveModel(model: string): string {
|
|
37
|
+
let out = model.trim();
|
|
38
|
+
if (out.includes("/")) {
|
|
39
|
+
const [prefix, rest] = out.split("/", 2);
|
|
40
|
+
const norm = prefix.toLowerCase();
|
|
41
|
+
if (norm === "gemini" || norm === "google") out = rest;
|
|
42
|
+
}
|
|
43
|
+
return out.startsWith("models/") ? out.slice("models/".length) : out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private asText(value: unknown): string {
|
|
47
|
+
if (typeof value === "string") return value;
|
|
48
|
+
if (value == null) return "";
|
|
49
|
+
try {
|
|
50
|
+
return JSON.stringify(value);
|
|
51
|
+
} catch {
|
|
52
|
+
return String(value);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private parseToolArgs(value: unknown): Record<string, unknown> {
|
|
57
|
+
if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
|
|
58
|
+
if (typeof value === "string") {
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(value);
|
|
61
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record<string, unknown>;
|
|
62
|
+
} catch {
|
|
63
|
+
// ignore invalid json and return empty args
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private toGemini(input: { messages: Array<Record<string, unknown>>; tools?: Array<Record<string, unknown>>; temperature?: number; maxTokens?: number }): {
|
|
70
|
+
systemInstruction?: { parts: Array<{ text: string }> };
|
|
71
|
+
contents: GeminiContent[];
|
|
72
|
+
tools?: Array<{ functionDeclarations: Array<Record<string, unknown>> }>;
|
|
73
|
+
toolConfig?: { functionCallingConfig: { mode: "AUTO" } };
|
|
74
|
+
generationConfig: { temperature: number; maxOutputTokens: number };
|
|
75
|
+
} {
|
|
76
|
+
const systemTexts: string[] = [];
|
|
77
|
+
const contents: GeminiContent[] = [];
|
|
78
|
+
|
|
79
|
+
for (const msg of input.messages) {
|
|
80
|
+
const role = String(msg.role ?? "");
|
|
81
|
+
|
|
82
|
+
if (role === "system") {
|
|
83
|
+
const txt = this.asText(msg.content).trim();
|
|
84
|
+
if (txt) systemTexts.push(txt);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (role === "user") {
|
|
89
|
+
const txt = this.asText(msg.content).trim();
|
|
90
|
+
if (!txt) continue;
|
|
91
|
+
contents.push({ role: "user", parts: [{ text: txt }] });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (role === "assistant" || role === "model") {
|
|
96
|
+
const parts: GeminiPart[] = [];
|
|
97
|
+
const txt = this.asText(msg.content).trim();
|
|
98
|
+
if (txt) parts.push({ text: txt });
|
|
99
|
+
|
|
100
|
+
const toolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
|
|
101
|
+
for (const tc of toolCalls) {
|
|
102
|
+
const fn = (tc as { function?: { name?: string; arguments?: unknown } }).function;
|
|
103
|
+
if (!fn?.name) continue;
|
|
104
|
+
parts.push({ functionCall: { name: fn.name, args: this.parseToolArgs(fn.arguments) } });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (parts.length) contents.push({ role: "model", parts });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (role === "tool") {
|
|
112
|
+
const name = typeof msg.name === "string" ? msg.name : "";
|
|
113
|
+
if (!name) continue;
|
|
114
|
+
const contentText = this.asText(msg.content);
|
|
115
|
+
contents.push({
|
|
116
|
+
role: "user",
|
|
117
|
+
parts: [{ functionResponse: { name, response: { result: contentText } } }],
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!contents.length) {
|
|
123
|
+
contents.push({ role: "user", parts: [{ text: "Hello" }] });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const out: {
|
|
127
|
+
systemInstruction?: { parts: Array<{ text: string }> };
|
|
128
|
+
contents: GeminiContent[];
|
|
129
|
+
tools?: Array<{ functionDeclarations: Array<Record<string, unknown>> }>;
|
|
130
|
+
toolConfig?: { functionCallingConfig: { mode: "AUTO" } };
|
|
131
|
+
generationConfig: { temperature: number; maxOutputTokens: number };
|
|
132
|
+
} = {
|
|
133
|
+
contents,
|
|
134
|
+
generationConfig: {
|
|
135
|
+
temperature: input.temperature ?? 0.1,
|
|
136
|
+
maxOutputTokens: Math.max(1, input.maxTokens ?? 4096),
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
if (systemTexts.length) {
|
|
141
|
+
out.systemInstruction = {
|
|
142
|
+
parts: [{ text: systemTexts.join("\n\n") }],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const tools = (input.tools ?? [])
|
|
147
|
+
.map((t) => t as OpenAITool)
|
|
148
|
+
.filter((t) => t.type === "function" && t.function?.name)
|
|
149
|
+
.map((t) => ({
|
|
150
|
+
name: t.function!.name!,
|
|
151
|
+
description: t.function?.description ?? "",
|
|
152
|
+
parameters: t.function?.parameters ?? { type: "object", properties: {} },
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
if (tools.length) {
|
|
156
|
+
out.tools = [{ functionDeclarations: tools }];
|
|
157
|
+
out.toolConfig = { functionCallingConfig: { mode: "AUTO" } };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async chat(input: { messages: Array<Record<string, unknown>>; tools?: Array<Record<string, unknown>>; model?: string; maxTokens?: number; temperature?: number }): Promise<LLMResponse> {
|
|
164
|
+
try {
|
|
165
|
+
const model = this.resolveModel(input.model ?? this.defaultModel);
|
|
166
|
+
const base = (this.apiBase ?? GeminiProvider.DEFAULT_BASE).replace(/\/$/, "");
|
|
167
|
+
const url = `${base}/models/${encodeURIComponent(model)}:generateContent`;
|
|
168
|
+
const body = this.toGemini(input);
|
|
169
|
+
|
|
170
|
+
const res = await fetch(url, {
|
|
171
|
+
method: "POST",
|
|
172
|
+
headers: {
|
|
173
|
+
"Content-Type": "application/json",
|
|
174
|
+
"x-goog-api-key": this.apiKey,
|
|
175
|
+
},
|
|
176
|
+
body: JSON.stringify(body),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const json: any = await res.json();
|
|
180
|
+
if (!res.ok) {
|
|
181
|
+
const msg = json?.error?.message ?? JSON.stringify(json);
|
|
182
|
+
return { content: `Error calling LLM: ${msg}`, toolCalls: [], finishReason: "error", usage: {}, reasoningContent: null };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const candidate = json?.candidates?.[0];
|
|
186
|
+
const parts: any[] = candidate?.content?.parts ?? [];
|
|
187
|
+
const textParts = parts.filter((p) => typeof p?.text === "string").map((p) => String(p.text));
|
|
188
|
+
const toolCalls: ToolCallRequest[] = parts
|
|
189
|
+
.filter((p) => p?.functionCall?.name)
|
|
190
|
+
.map((p) => ({
|
|
191
|
+
id: nanoid(9),
|
|
192
|
+
name: String(p.functionCall.name),
|
|
193
|
+
arguments: this.parseToolArgs(p.functionCall.args),
|
|
194
|
+
}));
|
|
195
|
+
|
|
196
|
+
const usage: Record<string, number> = {};
|
|
197
|
+
if (json?.usageMetadata) {
|
|
198
|
+
usage.prompt_tokens = Number(json.usageMetadata.promptTokenCount ?? 0);
|
|
199
|
+
usage.completion_tokens = Number(json.usageMetadata.candidatesTokenCount ?? 0);
|
|
200
|
+
usage.total_tokens = Number(json.usageMetadata.totalTokenCount ?? 0);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const finish = String(candidate?.finishReason ?? "STOP").toLowerCase();
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
content: textParts.length ? textParts.join("\n") : null,
|
|
207
|
+
toolCalls,
|
|
208
|
+
finishReason: finish,
|
|
209
|
+
usage,
|
|
210
|
+
reasoningContent: null,
|
|
211
|
+
};
|
|
212
|
+
} catch (err) {
|
|
213
|
+
return { content: `Error calling LLM: ${String(err)}`, toolCalls: [], finishReason: "error", usage: {}, reasoningContent: null };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { PROVIDERS, type Config, getApiBase, getProvider, getProviderName } from "../config/schema.js";
|
|
2
2
|
import type { LLMProvider } from "./base.js";
|
|
3
3
|
import { CustomProvider } from "./custom-provider.js";
|
|
4
|
+
import { GeminiProvider } from "./gemini-provider.js";
|
|
4
5
|
import { LiteLLMProvider } from "./litellm-provider.js";
|
|
5
6
|
|
|
6
7
|
export function findByModel(model: string): (typeof PROVIDERS)[number] | null {
|
|
@@ -29,7 +30,7 @@ export function makeProvider(config: Config): LLMProvider {
|
|
|
29
30
|
const p = getProvider(config, model);
|
|
30
31
|
|
|
31
32
|
const oauthProviders = new Set(["openai_codex", "github_copilot"]);
|
|
32
|
-
const unsupportedInTs = new Set(["anthropic"
|
|
33
|
+
const unsupportedInTs = new Set(["anthropic"]);
|
|
33
34
|
if (!providerName) {
|
|
34
35
|
throw new Error("No provider could be resolved from config. Run `pretticlaw onboard` and set provider/model/API key.");
|
|
35
36
|
}
|
|
@@ -43,6 +44,9 @@ export function makeProvider(config: Config): LLMProvider {
|
|
|
43
44
|
if (providerName === "custom") {
|
|
44
45
|
return new CustomProvider(p?.apiKey || "no-key", getApiBase(config, model) || "http://localhost:8000/v1", model);
|
|
45
46
|
}
|
|
47
|
+
if (providerName === "gemini") {
|
|
48
|
+
return new GeminiProvider(p?.apiKey || "", getApiBase(config, model), model);
|
|
49
|
+
}
|
|
46
50
|
|
|
47
51
|
return new LiteLLMProvider(p?.apiKey ?? null, getApiBase(config, model), model, providerName);
|
|
48
|
-
}
|
|
52
|
+
}
|
package/test/providers.test.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { DEFAULT_CONFIG, getProviderName } from "../src/config/schema.js";
|
|
|
3
3
|
import { findByModel } from "../src/providers/registry.js";
|
|
4
4
|
import { LiteLLMProvider } from "../src/providers/litellm-provider.js";
|
|
5
5
|
import { stripModelPrefix } from "../src/providers/registry.js";
|
|
6
|
+
import { makeProvider } from "../src/providers/registry.js";
|
|
7
|
+
import { GeminiProvider } from "../src/providers/gemini-provider.js";
|
|
6
8
|
|
|
7
9
|
describe("provider matching", () => {
|
|
8
10
|
test("matches github copilot with hyphen prefix", () => {
|
|
@@ -40,4 +42,13 @@ describe("provider matching", () => {
|
|
|
40
42
|
expect(stripModelPrefix("openai-codex/gpt-5.1-codex")).toBe("gpt-5.1-codex");
|
|
41
43
|
expect(stripModelPrefix("openai_codex/gpt-5.1-codex")).toBe("gpt-5.1-codex");
|
|
42
44
|
});
|
|
43
|
-
|
|
45
|
+
|
|
46
|
+
test("creates native gemini provider", () => {
|
|
47
|
+
const config = structuredClone(DEFAULT_CONFIG);
|
|
48
|
+
config.agents.defaults.provider = "gemini";
|
|
49
|
+
config.agents.defaults.model = "gemini/gemini-3-flash-preview";
|
|
50
|
+
config.providers.gemini.apiKey = "test-key";
|
|
51
|
+
const provider = makeProvider(config);
|
|
52
|
+
expect(provider).toBeInstanceOf(GeminiProvider);
|
|
53
|
+
});
|
|
54
|
+
});
|