diff --git a/README.md b/README.md index 9c9f6ae..5d49e9d 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,9 @@ export default handler(request, response) { ### Verification -#### `async verifyRequestByKeyId(rawBody, signature, keyId, options)` + + +#### `async verifyRequestByKeyId(rawBody, signature, keyId, requestOptions)` Verify the request payload using the provided signature and key ID. The method will request the public key from GitHub's API for the given keyId and then verify the payload. @@ -202,6 +204,87 @@ import { createDoneEvent } from "@copilot-extensions/preview-sdk"; response.write(createDoneEvent().toString()); ``` +### Parsing + + + +#### `parseRequestBody(body)` + +Parses the raw request body and returns an object with type support. + +⚠️ **It's well possible that the type is not 100% correct. Please send pull requests to `index.d.ts` to improve it** + +```js +import { parseRequestBody } from "@copilot-extensions/preview-sdk"; + +const payload = parseRequestBody(rawBody); +// When your IDE supports types, typing "payload." should prompt the available keys and their types. +``` + +#### `transformPayloadForOpenAICompatibility()` + +For cases when you want to pipe a user request directly to OpenAI, use this method to remove Copilot-specific fields from the request payload. + +```js +import { transformPayloadForOpenAICompatibility } from "@copilot-extensions/preview-sdk"; +import { OpenAI } from "openai"; + +const openaiPayload = transformPayloadForOpenAICompatibility(payload); + +const openai = new OpenAI({ apiKey: OPENAI_API_KEY }); +const stream = openai.beta.chat.completions.stream({ + ...openaiPayload, + model: "gpt-4-1106-preview", + stream: true, +}); +``` + +#### `verifyAndParseRequest()` + +Convenience method to verify and parse a request in one go. It calls [`verifyRequestByKeyId()`](#verifyRequestByKeyId) and [`parseRequestBody()`](#parseRequestBody) internally. + +```js +import { verifyAndParseRequest } from "@copilot-extensions/preview-sdk"; + +const { isValidRequest, payload } = await verifyAndParseRequest( + request, + signature, + key +); + +if (!isValidRequest) { + throw new Error("Request could not be verified"); +} + +// `payload` has type support. +``` + +#### `getUserMessage()` + +Convencience method to get the user's message from the request payload. + +```js +import { getUserMessage } from "@copilot-extensions/preview-sdk"; + +const userMessage = getUserMessage(payload); +``` + +#### `getUserConfirmation()` + +Convencience method to get the user's confirmation from the request payload (in case the user's last response was a confirmation). + +```js +import { getUserConfirmation } from "@copilot-extensions/preview-sdk"; + +const userConfirmation = getUserConfirmation(payload); + +if (userConfirmation) { + console.log("Received a user confirmation", userConfirmation); +} else { + // The user's last response was not a confirmation +} +``` + ## Dreamcode While implementing the lower-level functionality, we also dream big: what would our dream SDK for Coplitot extensions look like? Please have a look and share your thoughts and ideas: diff --git a/index.d.ts b/index.d.ts index 0f25568..ee3f478 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,5 +1,7 @@ import { request } from "@octokit/request"; +// verification types + type RequestInterface = typeof request; type RequestOptions = { request?: RequestInterface; @@ -34,6 +36,8 @@ interface VerifyRequestByKeyIdInterface { ): Promise; } +// response types + export interface CreateAckEventInterface { (): ResponseEvent<"ack"> } @@ -126,6 +130,122 @@ interface CopilotReference { }; } +// parse types + +export interface CopilotRequestPayload { + copilot_thread_id: string + messages: Message[] + stop: any + top_p: number + temperature: number + max_tokens: number + presence_penalty: number + frequency_penalty: number + copilot_skills: any[] + agent: string +} + +export interface OpenAICompatibilityPayload { + messages: { + role: string + name?: string + content: string + }[] +} + +export interface Message { + role: string + content: string + copilot_references: MessageCopilotReference[] + copilot_confirmations?: MessageCopilotConfirmation[] + name?: string +} + +export interface MessageCopilotReference { + type: string + data: CopilotReferenceData + id: string + is_implicit: boolean + metadata: CopilotReferenceMetadata +} + +export interface CopilotReferenceData { + type: string + id: number + name?: string + ownerLogin?: string + ownerType?: string + readmePath?: string + description?: string + commitOID?: string + ref?: string + refInfo?: CopilotReferenceDataRefInfo + visibility?: string + languages?: CopilotReferenceDataLanguage[] + login?: string + avatarURL?: string + url?: string +} + +export interface CopilotReferenceDataRefInfo { + name: string + type: string +} + +export interface CopilotReferenceDataLanguage { + name: string + percent: number +} + +export interface CopilotReferenceMetadata { + display_name: string + display_icon: string + display_url: string +} + +export interface MessageCopilotConfirmation { + state: "dismissed" | "accepted" + confirmation: { + id: string + [key: string]: unknown + } +} + +export interface ParseRequestBodyInterface { + (body: string): CopilotRequestPayload +} + +export interface TransformPayloadForOpenAICompatibilityInterface { + (payload: CopilotRequestPayload): OpenAICompatibilityPayload +} + + +export interface VerifyAndParseRequestInterface { + ( + body: string, + signature: string, + keyID: string, + requestOptions?: RequestOptions, + ): Promise<{ isValidRequest: boolean; payload: CopilotRequestPayload }>; +} + + +export interface GetUserMessageInterface { + (payload: CopilotRequestPayload): string; +} + +export type UserConfirmation = { + accepted: boolean; + id?: string; + metadata: Record; +} + +export interface GetUserConfirmationInterface { + (payload: CopilotRequestPayload): UserConfirmation | undefined; +} + +// exported methods + export declare const verifyRequest: VerifyRequestInterface; export declare const fetchVerificationKeys: FetchVerificationKeysInterface; export declare const verifyRequestByKeyId: VerifyRequestByKeyIdInterface; @@ -136,3 +256,9 @@ export declare const createDoneEvent: CreateDoneEventInterface; export declare const createErrorsEvent: CreateErrorsEventInterface; export declare const createReferencesEvent: CreateReferencesEventInterface; export declare const createTextEvent: CreateTextEventInterface; + +export declare const parseRequestBody: ParseRequestBodyInterface; +export declare const transformPayloadForOpenAICompatibility: TransformPayloadForOpenAICompatibilityInterface; +export declare const verifyAndParseRequest: VerifyAndParseRequestInterface; +export declare const getUserMessage: GetUserMessageInterface; +export declare const getUserConfirmation: GetUserConfirmationInterface; \ No newline at end of file diff --git a/index.js b/index.js index 686156a..71259ca 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,5 @@ // @ts-check -export * from "./lib/verification.js"; +export * from "./lib/parse.js"; export * from "./lib/response.js"; +export * from "./lib/verification.js"; diff --git a/index.test-d.ts b/index.test-d.ts index 14413b7..3e6ed22 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -11,16 +11,18 @@ import { fetchVerificationKeys, verifyRequest, verifyRequestByKeyId, + parseRequestBody, + transformPayloadForOpenAICompatibility, + verifyAndParseRequest, + getUserMessage, + getUserConfirmation, type VerificationPublicKey, + CopilotRequestPayload, } from "./index.js"; -const rawBody = ""; -const signature = ""; -const keyId = ""; -const key = "" const token = ""; -export async function verifyRequestByKeyIdTest() { +export async function verifyRequestByKeyIdTest(rawBody: string, signature: string, keyId: string) { const result = await verifyRequestByKeyId(rawBody, signature, keyId); expectType(result); @@ -43,7 +45,7 @@ export async function verifyRequestByKeyIdTest() { await verifyRequestByKeyId(rawBody, signature, keyId, { request }); } -export async function verifyRequestTest() { +export async function verifyRequestTest(rawBody: string, signature: string, key: string) { const result = await verifyRequest(rawBody, signature, key); expectType(result); @@ -227,3 +229,41 @@ export function createDoneEventTest() { // @ts-expect-error - .event is required event.event } + +export function parseRequestBodyTest(body: string) { + const result = parseRequestBody(body) + expectType(result); +} + +export function transformPayloadForOpenAICompatibilityTest(payload: CopilotRequestPayload) { + const result = transformPayloadForOpenAICompatibility(payload) + expectType<{ + messages: { + content: string; + role: string; + name?: string + }[] + } + >(result); +} + +export async function verifyAndParseRequestTest(rawBody: string, signature: string, keyId: string) { + const result = await verifyAndParseRequest(rawBody, signature, keyId) + expectType<{ isValidRequest: boolean, payload: CopilotRequestPayload }>(result); +} + +export function getUserMessageTest(payload: CopilotRequestPayload) { + const result = getUserMessage(payload) + expectType(result) +} + +export function getUserConfirmationTest(payload: CopilotRequestPayload) { + const result = getUserConfirmation(payload) + + if (result === undefined) { + expectType(result) + return + } + + expectType<{ accepted: boolean; id?: string; metadata: Record }>(result) +} diff --git a/lib/parse.js b/lib/parse.js new file mode 100644 index 0000000..83d84d2 --- /dev/null +++ b/lib/parse.js @@ -0,0 +1,57 @@ +// @ts-check + +import { verifyRequestByKeyId } from "./verification.js"; + +/** @type {import('..').ParseRequestBodyInterface} */ +export function parseRequestBody(body) { + return JSON.parse(body); +} + +/** @type {import('..').TransformPayloadForOpenAICompatibilityInterface} */ +export function transformPayloadForOpenAICompatibility(payload) { + return { + messages: payload.messages.map((message) => { + return { + role: message.role, + name: message.name, + content: message.content, + }; + }), + }; +} + +/** @type {import('..').VerifyAndParseRequestInterface} */ +export async function verifyAndParseRequest(body, signature, keyID, options) { + const isValidRequest = await verifyRequestByKeyId( + body, + signature, + keyID, + options + ); + + return { + isValidRequest, + payload: parseRequestBody(body), + }; +} + +/** @type {import('..').GetUserMessageInterface} */ +export function getUserMessage(payload) { + return payload.messages[payload.messages.length - 1].content; +} + +/** @type {import('..').GetUserConfirmationInterface} */ +export function getUserConfirmation(payload) { + const confirmation = + payload.messages[payload.messages.length - 1].copilot_confirmations?.[0]; + + if (!confirmation) return; + + const { id, ...metadata } = confirmation.confirmation; + + return { + accepted: confirmation.state === "accepted", + id, + metadata, + }; +} diff --git a/test/parse.test.js b/test/parse.test.js new file mode 100644 index 0000000..fe55173 --- /dev/null +++ b/test/parse.test.js @@ -0,0 +1,129 @@ +import { test, suite } from "node:test"; + +import { MockAgent } from "undici"; +import { request as defaultRequest } from "@octokit/request"; + +import { + getUserConfirmation, + getUserMessage, + parseRequestBody, + transformPayloadForOpenAICompatibility, + verifyAndParseRequest, +} from "../index.js"; +import { + CURRENT_PUBLIC_KEY, + KEY_ID, + RAW_BODY, + SIGNATURE, +} from "./verification.test.js"; + +suite("request parsing", () => { + test("parseRequestBody()", (t) => { + // parseRequestBody() does not check for structure. We assume it adheres + // to the expected structure when we verify that request came indeed + // from GitHub + const payload = parseRequestBody('{"messages": []}'); + t.assert.deepStrictEqual(payload.messages, []); + }); + + test("transformPayloadForOpenAICompatibility()", (t) => { + const payload = transformPayloadForOpenAICompatibility({ + messages: [], + someCopilotKey: "value", + }); + t.assert.deepStrictEqual(payload.messages, []); + }); + + test("verifyAndParseRequest()", async (t) => { + const mockAgent = new MockAgent(); + function fetchMock(url, opts) { + opts ||= {}; + opts.dispatcher = mockAgent; + return fetch(url, opts); + } + + mockAgent.disableNetConnect(); + const mockPool = mockAgent.get("https://api.github.com"); + mockPool + .intercept({ + method: "get", + path: `/meta/public_keys/copilot_api`, + }) + .reply( + 200, + { + public_keys: [ + { + key: CURRENT_PUBLIC_KEY, + key_identifier: KEY_ID, + is_current: true, + }, + ], + }, + { + headers: { + "content-type": "application/json", + "x-request-id": "", + }, + } + ); + const testRequest = defaultRequest.defaults({ + request: { fetch: fetchMock }, + }); + + const result = await verifyAndParseRequest(RAW_BODY, SIGNATURE, KEY_ID, { + request: testRequest, + }); + + t.assert.deepStrictEqual( + { isValidRequest: true, payload: JSON.parse(RAW_BODY) }, + result + ); + }); + + test("getUserMessage()", (t) => { + const payload = { + messages: [ + { + content: "Some previous message", + }, + { + content: "Hello, world!", + }, + ], + }; + const result = getUserMessage(payload); + t.assert.equal("Hello, world!", result); + }); + + test("getUserConfirmation()", (t) => { + const payload = { + messages: [ + { + content: "Some previous message", + }, + { + content: "Hello, world!", + copilot_confirmations: [ + { + state: "accepted", + confirmation: { + id: "some-confirmation-id", + someConfirmationMetadata: "value", + }, + }, + ], + }, + ], + }; + const result = getUserConfirmation(payload); + t.assert.deepStrictEqual( + { + accepted: true, + id: "some-confirmation-id", + metadata: { someConfirmationMetadata: "value" }, + }, + result + ); + }); +}); diff --git a/test/response.test.js b/test/response.test.js index 3f1a660..3b66b23 100644 --- a/test/response.test.js +++ b/test/response.test.js @@ -1,5 +1,3 @@ -// @ts-check - import { test, suite } from "node:test"; import { diff --git a/test/verification.test.js b/test/verification.test.js index 2484371..4ac89a9 100644 --- a/test/verification.test.js +++ b/test/verification.test.js @@ -10,17 +10,17 @@ import { verifyRequestByKeyId, } from "../index.js"; -const RAW_BODY = `{"copilot_thread_id":"9a1cc23a-ab73-498b-87a5-96c94cb7e3f3","messages":[{"role":"user","content":"@gr2m hi","copilot_references":[{"type":"github.repository","data":{"type":"repository","id":102985470,"name":"sandbox","ownerLogin":"gr2m","ownerType":"User","readmePath":"README.md","description":"@gr2m's little sandbox to play","commitOID":"9b04fffccbb818b2e317394463731b66f1ec5e89","ref":"refs/heads/main","refInfo":{"name":"main","type":"branch"},"visibility":"public","languages":[{"name":"JavaScript","percent":100}]},"id":"gr2m/sandbox","is_implicit":false,"metadata":{"display_name":"gr2m/sandbox","display_icon":"","display_url":""}}],"copilot_confirmations":null},{"role":"user","content":"@gr2m test","copilot_references":[{"type":"github.repository","data":{"type":"repository","id":102985470,"name":"sandbox","ownerLogin":"gr2m","ownerType":"User","readmePath":"README.md","description":"@gr2m's little sandbox to play","commitOID":"9b04fffccbb818b2e317394463731b66f1ec5e89","ref":"refs/heads/main","refInfo":{"name":"main","type":"branch"},"visibility":"public","languages":[{"name":"JavaScript","percent":100}]},"id":"gr2m/sandbox","is_implicit":false,"metadata":{"display_name":"gr2m/sandbox","display_icon":"","display_url":""}}],"copilot_confirmations":null},{"role":"user","content":"@gr2m test","copilot_references":[{"type":"github.repository","data":{"type":"repository","id":102985470,"name":"sandbox","ownerLogin":"gr2m","ownerType":"User","readmePath":"README.md","description":"@gr2m's little sandbox to play","commitOID":"9b04fffccbb818b2e317394463731b66f1ec5e89","ref":"refs/heads/main","refInfo":{"name":"main","type":"branch"},"visibility":"public","languages":[{"name":"JavaScript","percent":100}]},"id":"gr2m/sandbox","is_implicit":false,"metadata":{"display_name":"gr2m/sandbox","display_icon":"","display_url":""}}],"copilot_confirmations":null},{"role":"user","content":"Current Date and Time (UTC): 2024-08-26 19:43:13\\nUser's Current URL: https://github.com/gr2m/sandbox\\nCurrent User's Login: gr2m\\n","name":"_session","copilot_references":[],"copilot_confirmations":null},{"role":"user","content":"","copilot_references":[{"type":"github.repository","data":{"type":"repository","id":102985470,"name":"sandbox","ownerLogin":"gr2m","ownerType":"User","readmePath":"README.md","description":"@gr2m's little sandbox to play","commitOID":"9b04fffccbb818b2e317394463731b66f1ec5e89","ref":"refs/heads/main","refInfo":{"name":"main","type":"branch"},"visibility":"public","languages":[{"name":"JavaScript","percent":100}]},"id":"gr2m/sandbox","is_implicit":false,"metadata":{"display_name":"gr2m/sandbox","display_icon":"","display_url":""}}],"copilot_confirmations":null},{"role":"user","content":"test","copilot_references":[],"copilot_confirmations":[]}],"stop":null,"top_p":0,"temperature":0,"max_tokens":0,"presence_penalty":0,"frequency_penalty":0,"copilot_skills":null,"agent":"gr2m"}`; -const KEY_ID = +export const RAW_BODY = `{"copilot_thread_id":"9a1cc23a-ab73-498b-87a5-96c94cb7e3f3","messages":[{"role":"user","content":"@gr2m hi","copilot_references":[{"type":"github.repository","data":{"type":"repository","id":102985470,"name":"sandbox","ownerLogin":"gr2m","ownerType":"User","readmePath":"README.md","description":"@gr2m's little sandbox to play","commitOID":"9b04fffccbb818b2e317394463731b66f1ec5e89","ref":"refs/heads/main","refInfo":{"name":"main","type":"branch"},"visibility":"public","languages":[{"name":"JavaScript","percent":100}]},"id":"gr2m/sandbox","is_implicit":false,"metadata":{"display_name":"gr2m/sandbox","display_icon":"","display_url":""}}],"copilot_confirmations":null},{"role":"user","content":"@gr2m test","copilot_references":[{"type":"github.repository","data":{"type":"repository","id":102985470,"name":"sandbox","ownerLogin":"gr2m","ownerType":"User","readmePath":"README.md","description":"@gr2m's little sandbox to play","commitOID":"9b04fffccbb818b2e317394463731b66f1ec5e89","ref":"refs/heads/main","refInfo":{"name":"main","type":"branch"},"visibility":"public","languages":[{"name":"JavaScript","percent":100}]},"id":"gr2m/sandbox","is_implicit":false,"metadata":{"display_name":"gr2m/sandbox","display_icon":"","display_url":""}}],"copilot_confirmations":null},{"role":"user","content":"@gr2m test","copilot_references":[{"type":"github.repository","data":{"type":"repository","id":102985470,"name":"sandbox","ownerLogin":"gr2m","ownerType":"User","readmePath":"README.md","description":"@gr2m's little sandbox to play","commitOID":"9b04fffccbb818b2e317394463731b66f1ec5e89","ref":"refs/heads/main","refInfo":{"name":"main","type":"branch"},"visibility":"public","languages":[{"name":"JavaScript","percent":100}]},"id":"gr2m/sandbox","is_implicit":false,"metadata":{"display_name":"gr2m/sandbox","display_icon":"","display_url":""}}],"copilot_confirmations":null},{"role":"user","content":"Current Date and Time (UTC): 2024-08-26 19:43:13\\nUser's Current URL: https://github.com/gr2m/sandbox\\nCurrent User's Login: gr2m\\n","name":"_session","copilot_references":[],"copilot_confirmations":null},{"role":"user","content":"","copilot_references":[{"type":"github.repository","data":{"type":"repository","id":102985470,"name":"sandbox","ownerLogin":"gr2m","ownerType":"User","readmePath":"README.md","description":"@gr2m's little sandbox to play","commitOID":"9b04fffccbb818b2e317394463731b66f1ec5e89","ref":"refs/heads/main","refInfo":{"name":"main","type":"branch"},"visibility":"public","languages":[{"name":"JavaScript","percent":100}]},"id":"gr2m/sandbox","is_implicit":false,"metadata":{"display_name":"gr2m/sandbox","display_icon":"","display_url":""}}],"copilot_confirmations":null},{"role":"user","content":"test","copilot_references":[],"copilot_confirmations":[]}],"stop":null,"top_p":0,"temperature":0,"max_tokens":0,"presence_penalty":0,"frequency_penalty":0,"copilot_skills":null,"agent":"gr2m"}`; +export const KEY_ID = "4fe6b016179b74078ade7581abf4e84fb398c6fae4fb973972235b84fcd70ca3"; -const CURRENT_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- +export const CURRENT_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAELPuPiLVQbHY/clvpNnY+0BzYIXgo S0+XhEkTWUZEEznIVpS3rQseDTG6//gEWr4j9fY35+dGOxwOx3Z9mK3i7w== -----END PUBLIC KEY----- `; -const SIGNATURE = +export const SIGNATURE = "MEYCIQC8aEmkYA/4EQrXEOi2OL9nfpbnrCxkMc6HrH7b6SogKgIhAIYBThcpzkCCswiV1+pOaPI+zFQF9ShG61puoKs9rJjq"; test("smoke", (t) => {