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 ADDED
@@ -0,0 +1,5 @@
1
+ API_KEY=***
2
+ PROVIDER=openai
3
+ MODEL=gpt-4.1-mini
4
+ TEMPERATURE=0.1
5
+ MAX_TOKENS=8192
@@ -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"]
@@ -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", "gemini"]);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pretticlaw",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Lightweight AI Assistant That Lives in Your Computer",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -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" },
@@ -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", "gemini"]);
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
+ }
@@ -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
+ });