opencode-google-auth 0.0.1

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.
@@ -0,0 +1,176 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { transformRequest } from "./request"
3
+ import { geminiCliConfig, ProviderConfig } from "../lib/services/config"
4
+ import { Session } from "../lib/services/session"
5
+ import { Effect, Layer, pipe } from "effect"
6
+
7
+ describe("transformRequest", () => {
8
+ const baseParams = {
9
+ accessToken: "test-token",
10
+ projectId: "test-project-123",
11
+ }
12
+
13
+ const config = geminiCliConfig()
14
+
15
+ const MockSession = Layer.succeed(
16
+ Session,
17
+ Session.of({
18
+ getAccessToken: Effect.succeed(baseParams.accessToken),
19
+ ensureProject: Effect.succeed({
20
+ cloudaicompanionProject: baseParams.projectId,
21
+ currentTier: {
22
+ id: "free",
23
+ name: "Free",
24
+ description: "",
25
+ userDefinedCloudaicompanionProject: false,
26
+ },
27
+ allowedTiers: [],
28
+ gcpManaged: false,
29
+ manageSubscriptionUri: "",
30
+ }),
31
+ setCredentials: () => Effect.void,
32
+ } as unknown as Session),
33
+ ).pipe(Layer.provideMerge(Layer.succeed(ProviderConfig, config)))
34
+
35
+ const runTransform = (
36
+ input: Parameters<typeof fetch>[0],
37
+ init: Parameters<typeof fetch>[1],
38
+ endpoint = "https://example.com",
39
+ ) =>
40
+ pipe(
41
+ transformRequest(input, init, endpoint),
42
+ Effect.provide(MockSession),
43
+ Effect.runPromise,
44
+ )
45
+
46
+ describe("URL transformation", () => {
47
+ it("transforms /v1beta/models/{model}:{action} to /v1internal:{action}", async () => {
48
+ const result = await runTransform(
49
+ "https://example.com/v1beta/models/gemini-2.5-pro:generateContent",
50
+ undefined,
51
+ )
52
+
53
+ expect(result.input).toContain("/v1internal:generateContent")
54
+ expect(result.streaming).toBe(false)
55
+ })
56
+
57
+ it("detects streaming requests", async () => {
58
+ const result = await runTransform(
59
+ "https://example.com/v1beta/models/gemini-2.5-pro:streamGenerateContent",
60
+ undefined,
61
+ )
62
+
63
+ expect(result.input).toContain("/v1internal:streamGenerateContent")
64
+ expect(result.input).toContain("alt=sse")
65
+ expect(result.streaming).toBe(true)
66
+ })
67
+
68
+ it("passes through non-matching URLs", async () => {
69
+ const url = "https://example.com/some/other/path"
70
+ const result = await runTransform(url, undefined)
71
+
72
+ expect(result.input).toBe(url)
73
+ expect(result.streaming).toBe(false)
74
+ })
75
+ })
76
+
77
+ describe("header transformation", () => {
78
+ it("sets Authorization header", async () => {
79
+ const result = await runTransform(
80
+ "https://example.com/v1beta/models/gemini-2.5-pro:generateContent",
81
+ {},
82
+ )
83
+
84
+ const headers = result.init.headers as Headers
85
+ expect(headers.get("Authorization")).toBe("Bearer test-token")
86
+ })
87
+
88
+ it("removes x-api-key header", async () => {
89
+ const result = await runTransform(
90
+ "https://example.com/v1beta/models/gemini-2.5-pro:generateContent",
91
+ {
92
+ headers: {
93
+ "x-api-key": "some-key",
94
+ "x-goog-api-key": "another-key",
95
+ },
96
+ },
97
+ )
98
+
99
+ const headers = result.init.headers as Headers
100
+ expect(headers.get("x-api-key")).toBeNull()
101
+ expect(headers.get("x-goog-api-key")).toBeNull()
102
+ })
103
+
104
+ it("sets Accept header for streaming", async () => {
105
+ const result = await runTransform(
106
+ "https://example.com/v1beta/models/gemini-2.5-pro:streamGenerateContent",
107
+ {},
108
+ )
109
+
110
+ const headers = result.init.headers as Headers
111
+ expect(headers.get("Accept")).toBe("text/event-stream")
112
+ })
113
+
114
+ it("applies provider-specific headers", async () => {
115
+ const result = await runTransform(
116
+ "https://example.com/v1beta/models/gemini-2.5-pro:generateContent",
117
+ {},
118
+ )
119
+
120
+ const headers = result.init.headers as Headers
121
+ expect(headers.get("User-Agent")).toBe(
122
+ config.HEADERS["User-Agent"] ?? null,
123
+ )
124
+ expect(headers.get("X-Goog-Api-Client")).toBe(
125
+ config.HEADERS["X-Goog-Api-Client"] ?? null,
126
+ )
127
+ })
128
+ })
129
+
130
+ describe("body transformation", () => {
131
+ it("wraps request body with project and model", async () => {
132
+ const originalBody = JSON.stringify({ contents: [{ text: "Hello" }] })
133
+
134
+ const result = await runTransform(
135
+ "https://example.com/v1beta/models/gemini-2.5-pro:generateContent",
136
+ { body: originalBody },
137
+ )
138
+
139
+ const body = JSON.parse(result.init.body as string)
140
+ expect(body.project).toBe("test-project-123")
141
+ expect(body.model).toBe("gemini-2.5-pro")
142
+ expect(body.request).toEqual({ contents: [{ text: "Hello" }] })
143
+ })
144
+
145
+ it("preserves body if not JSON", async () => {
146
+ const originalBody = "not-json"
147
+
148
+ const result = await runTransform(
149
+ "https://example.com/v1beta/models/gemini-2.5-pro:generateContent",
150
+ { body: originalBody },
151
+ )
152
+
153
+ expect(result.init.body).toBe("not-json")
154
+ })
155
+ })
156
+
157
+ describe("input types", () => {
158
+ it("handles URL object", async () => {
159
+ const url = new URL(
160
+ "https://example.com/v1beta/models/gemini-2.5-pro:generateContent",
161
+ )
162
+ const result = await runTransform(url, undefined)
163
+
164
+ expect(result.input).toContain("/v1internal:generateContent")
165
+ })
166
+
167
+ it("handles Request object", async () => {
168
+ const request = new Request(
169
+ "https://example.com/v1beta/models/gemini-2.5-pro:generateContent",
170
+ )
171
+ const result = await runTransform(request, undefined)
172
+
173
+ expect(result.input).toContain("/v1internal:generateContent")
174
+ })
175
+ })
176
+ })
@@ -0,0 +1,102 @@
1
+ import { regex } from "arkregex"
2
+ import { Effect, pipe } from "effect"
3
+ import {
4
+ CODE_ASSIST_VERSION,
5
+ ProviderConfig,
6
+ type RequestContext,
7
+ } from "../lib/services/config"
8
+ import { Session } from "../lib/services/session"
9
+
10
+ const STREAM_ACTION = "streamGenerateContent"
11
+ const PATH_PATTERN = regex("/models/(?<model>[^:]+):(?<action>\\w+)")
12
+
13
+ export const transformRequest = Effect.fn("transformRequest")(function* (
14
+ input: Parameters<typeof fetch>[0],
15
+ init: Parameters<typeof fetch>[1],
16
+ endpoint: string,
17
+ ) {
18
+ const config = yield* ProviderConfig
19
+ const session = yield* Session
20
+ const accessToken = yield* session.getAccessToken
21
+ const project = yield* session.ensureProject
22
+ const projectId = project.cloudaicompanionProject
23
+
24
+ const url = new URL(input instanceof Request ? input.url : input)
25
+
26
+ // Rewrite the URL to use the specified endpoint
27
+ const endpointUrl = new URL(endpoint)
28
+ url.protocol = endpointUrl.protocol
29
+ url.host = endpointUrl.host
30
+
31
+ const match = PATH_PATTERN.exec(url.pathname)
32
+ if (!match) {
33
+ return {
34
+ input: url.toString(),
35
+ init: init ?? {},
36
+ streaming: false,
37
+ }
38
+ }
39
+
40
+ const { model, action } = match.groups
41
+ const streaming = action === STREAM_ACTION
42
+
43
+ // Transform URL to internal endpoint
44
+ url.pathname = `/${CODE_ASSIST_VERSION}:${action}`
45
+ if (streaming) {
46
+ url.searchParams.set("alt", "sse")
47
+ }
48
+
49
+ // Transform headers
50
+ const headers = new Headers(init?.headers)
51
+ headers.delete("x-api-key")
52
+ // headers.delete("x-goog-api-key")
53
+ headers.set("x-opencode-tools-debug", "1")
54
+ headers.set("Authorization", `Bearer ${accessToken}`)
55
+
56
+ for (const [key, value] of Object.entries(config.HEADERS)) {
57
+ headers.set(key, value)
58
+ }
59
+
60
+ if (streaming) {
61
+ headers.set("Accept", "text/event-stream")
62
+ }
63
+
64
+ // Wrap and transform request
65
+ const isJson = typeof init?.body === "string"
66
+ const parsedBody = yield* pipe(
67
+ Effect.try(() => (isJson ? JSON.parse(init.body as string) : null)),
68
+ Effect.orElseSucceed(() => null),
69
+ )
70
+
71
+ const wrappedBody = {
72
+ project: projectId,
73
+ model,
74
+ request: parsedBody ?? {},
75
+ }
76
+
77
+ const {
78
+ body: transformedBody,
79
+ headers: finalHeaders,
80
+ url: finalUrl,
81
+ } =
82
+ config.transformRequest ?
83
+ yield* config.transformRequest({
84
+ body: wrappedBody,
85
+ headers,
86
+ url,
87
+ } satisfies RequestContext)
88
+ : { body: wrappedBody, headers, url }
89
+
90
+ const finalBody =
91
+ isJson && parsedBody ? JSON.stringify(transformedBody) : init?.body
92
+
93
+ return {
94
+ input: finalUrl.toString(),
95
+ init: {
96
+ ...init,
97
+ headers: finalHeaders,
98
+ body: finalBody,
99
+ },
100
+ streaming,
101
+ }
102
+ })
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { transformNonStreamingResponse } from "./response"
3
+
4
+ describe("transformNonStreamingResponse", () => {
5
+ it("unwraps { response: X } to X", async () => {
6
+ const original = new Response(
7
+ JSON.stringify({ response: { text: "Hello" } }),
8
+ { headers: { "content-type": "application/json" } },
9
+ )
10
+
11
+ const result = await transformNonStreamingResponse(original)
12
+ const body = await result.json()
13
+
14
+ expect(body).toEqual({ text: "Hello" })
15
+ })
16
+
17
+ it("passes through response without wrapper", async () => {
18
+ const original = new Response(JSON.stringify({ text: "Hello" }), {
19
+ headers: { "content-type": "application/json" },
20
+ })
21
+
22
+ const result = await transformNonStreamingResponse(original)
23
+ const body = await result.json()
24
+
25
+ expect(body).toEqual({ text: "Hello" })
26
+ })
27
+
28
+ it("passes through non-JSON responses", async () => {
29
+ const original = new Response("plain text", {
30
+ headers: { "content-type": "text/plain" },
31
+ })
32
+
33
+ const result = await transformNonStreamingResponse(original)
34
+ const body = await result.text()
35
+
36
+ expect(body).toBe("plain text")
37
+ })
38
+
39
+ it("preserves status code", async () => {
40
+ const original = new Response(JSON.stringify({ response: {} }), {
41
+ status: 201,
42
+ statusText: "Created",
43
+ headers: { "content-type": "application/json" },
44
+ })
45
+
46
+ const result = await transformNonStreamingResponse(original)
47
+
48
+ expect(result.status).toBe(201)
49
+ expect(result.statusText).toBe("Created")
50
+ })
51
+
52
+ it("preserves headers", async () => {
53
+ const original = new Response(JSON.stringify({ response: {} }), {
54
+ headers: {
55
+ "content-type": "application/json",
56
+ "x-custom-header": "value",
57
+ },
58
+ })
59
+
60
+ const result = await transformNonStreamingResponse(original)
61
+
62
+ expect(result.headers.get("x-custom-header")).toBe("value")
63
+ })
64
+
65
+ it("handles malformed JSON gracefully", async () => {
66
+ const original = new Response("not-json{", {
67
+ headers: { "content-type": "application/json" },
68
+ })
69
+
70
+ const result = await transformNonStreamingResponse(original)
71
+ const body = await result.text()
72
+
73
+ expect(body).toBe("not-json{")
74
+ })
75
+ })
@@ -0,0 +1,27 @@
1
+ export const transformNonStreamingResponse = async (
2
+ response: Response,
3
+ ): Promise<Response> => {
4
+ const contentType = response.headers.get("content-type")
5
+
6
+ if (!contentType?.includes("application/json")) {
7
+ return response
8
+ }
9
+
10
+ try {
11
+ const cloned = response.clone()
12
+ const parsed = (await cloned.json()) as { response?: unknown }
13
+
14
+ if (parsed.response !== undefined) {
15
+ const { response: responseData, ...rest } = parsed
16
+ return new Response(JSON.stringify({ ...rest, ...responseData }), {
17
+ status: response.status,
18
+ statusText: response.statusText,
19
+ headers: response.headers,
20
+ })
21
+ }
22
+ } catch {
23
+ // Return original if parse fails
24
+ }
25
+
26
+ return response
27
+ }
@@ -0,0 +1,108 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { transformStreamingResponse } from "./stream"
3
+
4
+ describe("transformStreamingResponse", () => {
5
+ const createSSEStream = (events: string[]) => {
6
+ const encoder = new TextEncoder()
7
+ return new ReadableStream({
8
+ start(controller) {
9
+ for (const event of events) {
10
+ controller.enqueue(encoder.encode(event))
11
+ }
12
+ controller.close()
13
+ },
14
+ })
15
+ }
16
+
17
+ it("unwraps response wrapper from SSE events", async () => {
18
+ const stream = createSSEStream([
19
+ 'data: {"response":{"text":"Hello"}}\n\n',
20
+ 'data: {"response":{"text":" World"}}\n\n',
21
+ ])
22
+
23
+ const response = new Response(stream, {
24
+ headers: { "content-type": "text/event-stream" },
25
+ })
26
+
27
+ const result = await transformStreamingResponse(response)
28
+ const text = await result.text()
29
+
30
+ expect(text).toContain('data: {"text":"Hello"}')
31
+ expect(text).toContain('data: {"text":" World"}')
32
+ })
33
+
34
+ it("handles responses without wrapper", async () => {
35
+ const stream = createSSEStream(['data: {"text":"Direct"}\n\n'])
36
+
37
+ const response = new Response(stream, {
38
+ headers: { "content-type": "text/event-stream" },
39
+ })
40
+
41
+ const result = await transformStreamingResponse(response)
42
+ const text = await result.text()
43
+
44
+ expect(text).toContain('data: {"text":"Direct"}')
45
+ })
46
+
47
+ it("returns original response if no body", async () => {
48
+ const response = new Response(null, { status: 204 })
49
+
50
+ const result = await transformStreamingResponse(response)
51
+
52
+ expect(result.status).toBe(204)
53
+ })
54
+
55
+ it("preserves status and headers", async () => {
56
+ const stream = createSSEStream(['data: {"response":{}}\n\n'])
57
+
58
+ const response = new Response(stream, {
59
+ status: 200,
60
+ statusText: "OK",
61
+ headers: {
62
+ "content-type": "text/event-stream",
63
+ "x-custom": "value",
64
+ },
65
+ })
66
+
67
+ const result = await transformStreamingResponse(response)
68
+
69
+ expect(result.status).toBe(200)
70
+ expect(result.headers.get("x-custom")).toBe("value")
71
+ })
72
+
73
+ it("handles chunked SSE data", async () => {
74
+ // Simulate data split across chunks
75
+ const stream = createSSEStream([
76
+ 'data: {"resp',
77
+ 'onse":{"part":"1"}}\n\ndata: {"response":{"part":"2"}}\n\n',
78
+ ])
79
+
80
+ const response = new Response(stream, {
81
+ headers: { "content-type": "text/event-stream" },
82
+ })
83
+
84
+ const result = await transformStreamingResponse(response)
85
+ const text = await result.text()
86
+
87
+ expect(text).toContain('data: {"part":"1"}')
88
+ expect(text).toContain('data: {"part":"2"}')
89
+ })
90
+
91
+ it("filters out non-data lines", async () => {
92
+ const stream = createSSEStream([
93
+ ": comment\n",
94
+ "event: message\n",
95
+ 'data: {"response":{"valid":"data"}}\n\n',
96
+ ])
97
+
98
+ const response = new Response(stream, {
99
+ headers: { "content-type": "text/event-stream" },
100
+ })
101
+
102
+ const result = await transformStreamingResponse(response)
103
+ const text = await result.text()
104
+
105
+ expect(text).toContain('data: {"valid":"data"}')
106
+ expect(text).not.toContain("comment")
107
+ })
108
+ })
@@ -0,0 +1,61 @@
1
+ import {
2
+ encoder,
3
+ type Event,
4
+ makeChannel,
5
+ Retry,
6
+ } from "@effect/experimental/Sse"
7
+ import { pipe, Stream } from "effect"
8
+
9
+ const parseAndMerge = (event: Event): string => {
10
+ if (!event.data) {
11
+ return encoder.write(event)
12
+ }
13
+
14
+ try {
15
+ const parsed = JSON.parse(event.data) as {
16
+ response?: Record<string, unknown>
17
+ }
18
+ if (parsed.response) {
19
+ const { response, ...rest } = parsed
20
+ return encoder.write({
21
+ ...event,
22
+ data: JSON.stringify({ ...rest, ...response }),
23
+ })
24
+ }
25
+
26
+ return encoder.write(event)
27
+ } catch {
28
+ return encoder.write(event)
29
+ }
30
+ }
31
+
32
+ const parseSSE = (body: ReadableStream<Uint8Array>) =>
33
+ pipe(
34
+ Stream.fromReadableStream(
35
+ () => body,
36
+ (error) => error,
37
+ ),
38
+ Stream.decodeText,
39
+ Stream.pipeThroughChannel(makeChannel()),
40
+ Stream.map((event) =>
41
+ Retry.is(event) ? encoder.write(event) : parseAndMerge(event),
42
+ ),
43
+ Stream.encodeText,
44
+ )
45
+
46
+ export const transformStreamingResponse = (response: Response) => {
47
+ if (!response.body) {
48
+ return response
49
+ }
50
+
51
+ const transformed = parseSSE(response.body)
52
+ const readable = Stream.toReadableStream(
53
+ transformed as Stream.Stream<Uint8Array, never, never>,
54
+ )
55
+
56
+ return new Response(readable, {
57
+ status: response.status,
58
+ statusText: response.statusText,
59
+ headers: response.headers,
60
+ })
61
+ }
package/src/types.ts ADDED
@@ -0,0 +1,66 @@
1
+ import type { Hooks, PluginInput } from "@opencode-ai/plugin"
2
+
3
+ export type OpenCodeConfigHook = NonNullable<Hooks["config"]>
4
+ export type OpenCodeConfig = Parameters<OpenCodeConfigHook>[0]
5
+ export type OpenCodeProvider = NonNullable<OpenCodeConfig["provider"]>[string]
6
+ export type OpenCodeModel = NonNullable<OpenCodeProvider["models"]>[string]
7
+
8
+ export type OpenCodeAuthHook = NonNullable<Hooks["auth"]>
9
+ export type OpenCodeAuthLoader = NonNullable<OpenCodeAuthHook["loader"]>
10
+ export type OpenCodeAuthMethod = NonNullable<
11
+ OpenCodeAuthHook["methods"]
12
+ >[number]
13
+
14
+ export type OpenCodeLogLevel = NonNullable<
15
+ NonNullable<Parameters<PluginInput["client"]["app"]["log"]>[0]>["body"]
16
+ >["level"]
17
+
18
+ export type BunServeOptions = Partial<Bun.Serve.Options<undefined, never>>
19
+
20
+ /**
21
+ * Subset of google-auth-library Credentials type.
22
+ *
23
+ * Why don't they fucking use object union instead of making everything nullable.
24
+ */
25
+ export interface Credentials {
26
+ access_token: string
27
+ refresh_token: string
28
+ expiry_date: number
29
+ }
30
+
31
+ /**
32
+ * Types for models.dev, simplified
33
+ */
34
+
35
+ interface InterleavedConfig {
36
+ field: string
37
+ }
38
+
39
+ export interface Model {
40
+ id: string
41
+ name: string
42
+ family: string
43
+ attachment: boolean
44
+ reasoning: boolean
45
+ tool_call: boolean
46
+ temperature: boolean
47
+ knowledge?: string
48
+ release_date?: string
49
+ last_updated?: string
50
+ open_weights: boolean
51
+ structured_output?: boolean
52
+ interleaved?: boolean | InterleavedConfig
53
+ status?: string
54
+ }
55
+
56
+ export interface Provider {
57
+ id: string
58
+ env: string[]
59
+ npm: string
60
+ name: string
61
+ doc: string
62
+ models: Record<string, Model>
63
+ api?: string
64
+ }
65
+
66
+ export type ModelsDev = Record<string, Provider>