diff --git a/.devcontainer/install-dependencies.sh b/.devcontainer/install-dependencies.sh index 1391576..0ff741c 100755 --- a/.devcontainer/install-dependencies.sh +++ b/.devcontainer/install-dependencies.sh @@ -34,6 +34,7 @@ echo 'export PATH="$(pwd)/wabt-1.0.35/bin:$PATH"' >> ~/.bashrc # Install Emscripten git clone https://github.com/emscripten-core/emsdk.git cd emsdk +git checkout 3.1.74 ./emsdk install latest ./emsdk activate latest cd .. diff --git a/examples/aiproxy/.gitignore b/examples/aiproxy/.gitignore index f06f1de..063f2a3 100644 --- a/examples/aiproxy/.gitignore +++ b/examples/aiproxy/.gitignore @@ -2,3 +2,4 @@ target .spin dist web4.js +.env diff --git a/examples/aiproxy/.test.env b/examples/aiproxy/.test.env index 2894be3..3235685 100644 --- a/examples/aiproxy/.test.env +++ b/examples/aiproxy/.test.env @@ -1,5 +1,6 @@ SPIN_VARIABLE_OPENAI_API_KEY=abcd +SPIN_VARIABLE_OPENAI_API_KEY_METHOD=authorization # or "api-key" SPIN_VARIABLE_REFUND_SIGNING_KEY=48QM3KLHFY22hDNnDx6zvgakY1dy66Jsv4dtTT6mt131DtjvPrQn7zyr3CVb1ZKPuVLbmvjQSK9o5vuEvMyiLR5Y SPIN_VARIABLE_FT_CONTRACT_ID=aitoken.test.near SPIN_VARIABLE_OPENAI_COMPLETIONS_ENDPOINT=http://127.0.0.1:3001/v1/chat/completions -SPIN_VARIABLE_RPC_URL=http://localhost:14500 \ No newline at end of file +SPIN_VARIABLE_RPC_URL=http://localhost:14500 diff --git a/examples/aiproxy/README.md b/examples/aiproxy/README.md index 626189e..75d3032 100644 --- a/examples/aiproxy/README.md +++ b/examples/aiproxy/README.md @@ -4,13 +4,14 @@ This folder contains a [Spin](https://www.fermyon.com/spin) application, based o There is a simple example of a web client in the [web](./web/) folder. -The application will keep track of of token usage per conversation in the built-in key-value storage of Spin. The initial balance for a conversation is retrieved from the Fungible Token smart contract. +The application will keep track of token usage per conversation in the built-in key-value storage of Spin. The initial balance for a conversation is retrieved from the Fungible Token smart contract. To launch the application, make sure to have the Spin SDK installed. You also need to set some environment variables: - `SPIN_VARIABLE_OPENAI_API_KEY` your OpenAI API key. +- `SPIN_VARIABLE_OPENAI_API_KEY_METHOD` specifies the method to provide the API key. Use `authorization` for OpenAI (default) and `api-key` for Azure OpenAI. - `SPIN_VARIABLE_REFUND_SIGNING_KEY` an ed21159 secret key that will be used to sign refund requests. You can run the [create-refund-signing-keypair.js](./create-refund-signing-keypair.js) script to create the keypair. Run it using the command `$(node create-refund-signing-keypair.js)` and it will set the environment variable for you. - `SPIN_VARIABLE_FT_CONTRACT_ID` the NEAR contract account id. e.g `aitoken.test.near` - `SPIN_VARIABLE_OPENAI_COMPLETIONS_ENDPOINT` OpenAI API completions endpoint. E.g. https://api.openai.com/v1/chat/completions diff --git a/examples/aiproxy/openai-proxy/src/lib.rs b/examples/aiproxy/openai-proxy/src/lib.rs index c71194b..1e45838 100644 --- a/examples/aiproxy/openai-proxy/src/lib.rs +++ b/examples/aiproxy/openai-proxy/src/lib.rs @@ -247,6 +247,19 @@ async fn handle_request(request: Request, response_out: ResponseOutparam) { match proxy_openai(messages).await { Ok(incoming_response) => { + if incoming_response.status() != 200 { + conversation_balance.locked_for_ongoing_request = false; + conversation_balance_store + .set_json(conversation_id, &conversation_balance) + .unwrap(); + let response_data = incoming_response.into_body().await.unwrap(); + let response_string = String::from_utf8(response_data).unwrap(); + eprintln!( + "error in response from OpenAI endpoint: {:?}", + response_string + ); + return server_error(response_out); + } let mut incoming_response_body = incoming_response.take_body_stream(); let outgoing_response = OutgoingResponse::new(headers); let mut outgoing_response_body = outgoing_response.take_body(); @@ -458,18 +471,24 @@ async fn proxy_openai(messages: Value) -> anyhow::Result { }); let openai_completions_endpoint = variables::get("openai_completions_endpoint")?; - let outgoing_request = Request::builder() + let api_key = variables::get("openai_api_key").unwrap(); + let api_key_method = + variables::get("openai_api_key_method").unwrap_or_else(|_| "authorization".to_string()); + + let mut openai_request_builder = Request::builder(); + openai_request_builder .method(Method::Post) .uri(openai_completions_endpoint) - .header( - "Authorization", - format!("Bearer {}", variables::get("openai_api_key").unwrap()), - ) - .header("Content-Type", "application/json") - .body(request_body.to_string()) - .build(); - - let response = match http::send::<_, IncomingResponse>(outgoing_request).await { + .header("Content-Type", "application/json"); + + let openai_request = match api_key_method.as_str() { + "api-key" => openai_request_builder.header("Api-Key", api_key), + _ => openai_request_builder.header("Authorization", format!("Bearer {}", api_key)), + } + .body(request_body.to_string()) + .build(); + + let response = match http::send::<_, IncomingResponse>(openai_request).await { Ok(resp) => resp, Err(e) => { eprintln!("Error sending request to OpenAI: {e}"); diff --git a/examples/aiproxy/playwright-tests/aiproxy.spec.js b/examples/aiproxy/playwright-tests/aiproxy.spec.js index 81d9964..340e733 100644 --- a/examples/aiproxy/playwright-tests/aiproxy.spec.js +++ b/examples/aiproxy/playwright-tests/aiproxy.spec.js @@ -1,7 +1,64 @@ - import { test, expect } from '@playwright/test'; +import { spawn } from 'child_process'; +import { fileURLToPath } from 'url'; +import path from 'path'; +import http from 'http'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const mockServerPath = path.resolve(__dirname, 'openaimockserver.js'); + +let mockServerProcess; + +async function startMockServer(apiKeyMethod, apikey = 'abcd') { + if (mockServerProcess) { + await new Promise((resolve) => { + mockServerProcess.on('close', resolve); + mockServerProcess.kill(); + }); + } + + mockServerProcess = spawn('node', [mockServerPath], { + env: { + ...process.env, + SPIN_VARIABLE_OPENAI_API_KEY: apikey, + SPIN_VARIABLE_OPENAI_API_KEY_METHOD: apiKeyMethod, + }, + }); + + mockServerProcess.stdout.on('data', (data) => { + console.log(`stdout: ${data}`); + }); + + mockServerProcess.stderr.on('data', (data) => { + console.error(`stderr: ${data}`); + }); + + // Wait for the server to start and respond on port 3001 + await new Promise((resolve, reject) => { + const interval = setInterval(() => { + http.get('http://127.0.0.1:3001', (res) => { + if (res.statusCode === 200) { + clearInterval(interval); + resolve(); + } + }).on('error', () => { + // Ignore errors, keep trying + }); + }, 500); + }); +} + +test.afterEach(async () => { + if (mockServerProcess) { + await new Promise((resolve) => { + mockServerProcess.on('close', resolve); + mockServerProcess.kill(); + }); + } +}); -test('ask question', async ({ page }) => { +async function testConversation({page, expectedRefundAmount = "127999973", expectedOpenAIResponse = "Hello! How can I assist you today?"}) { const { functionAccessKeyPair, publicKey, accountId, contractId } = await fetch('http://localhost:14501').then(r => r.json()); await page.goto('/'); @@ -20,10 +77,28 @@ test('ask question', async ({ page }) => { questionArea.fill("Hello!"); await page.waitForTimeout(500); await page.getByRole('button', { name: 'Ask AI' }).click(); - await expect(await page.getByText("Hello! How can I assist you today?")).toBeVisible(); + await expect(await page.getByText(expectedOpenAIResponse)).toBeVisible(); await page.waitForTimeout(500); await page.locator("#refundButton").click(); - await expect(await page.locator("#refund_message")).toContainText(`EVENT_JSON:{"standard":"nep141","version":"1.0.0","event":"ft_transfer","data":[{"old_owner_id":"${contractId}","new_owner_id":"${accountId}","amount":"127999973"}]}\nrefunded 127999973 to ${accountId}`); + await expect(await page.locator("#refund_message")).toContainText(`EVENT_JSON:{"standard":"nep141","version":"1.0.0","event":"ft_transfer","data":[{"old_owner_id":"${contractId}","new_owner_id":"${accountId}","amount":"${expectedRefundAmount}"}]}\nrefunded ${expectedRefundAmount} to ${accountId}`, {timeout: 10_000}); +} + +test('start conversation, ask question and refund (using OpenAI authorization header)', async ({ page }) => { + await startMockServer('authorization'); + await testConversation({page}); +}); + + + +test('start conversation, ask question and refund (using Azure OpenAI Api-Key header)', async ({ page }) => { + await startMockServer('api-key'); + await testConversation({page}); +}); + +test('start conversation, ask question, where openai API fails, and refund (using wrong OpenAI API key)', async ({ page }) => { + await startMockServer('api-key', "1234ffff"); + + await testConversation({page, expectedRefundAmount: "128000000", expectedOpenAIResponse: "Failed to fetch from proxy: Internal Server Error"}); }); diff --git a/examples/aiproxy/playwright-tests/near_rpc.js b/examples/aiproxy/playwright-tests/near_rpc.js index f98042f..dfa0e40 100644 --- a/examples/aiproxy/playwright-tests/near_rpc.js +++ b/examples/aiproxy/playwright-tests/near_rpc.js @@ -37,7 +37,7 @@ await aiuser.call(aiTokenAccount.accountId, 'storage_deposit', { await aiTokenAccount.call(aiTokenAccount.accountId, 'ft_transfer', { receiver_id: aiuser.accountId, - amount: 128_000_000n.toString(), + amount: (100n*128_000_000n).toString(), }, { attachedDeposit: 1n.toString() }); diff --git a/examples/aiproxy/playwright-tests/openaimockserver.js b/examples/aiproxy/playwright-tests/openaimockserver.js index 5f1ffc7..da255fd 100644 --- a/examples/aiproxy/playwright-tests/openaimockserver.js +++ b/examples/aiproxy/playwright-tests/openaimockserver.js @@ -2,6 +2,8 @@ import { createServer } from "http"; import { Readable } from "stream"; const PORT = 3001; +const API_KEY = process.env.SPIN_VARIABLE_OPENAI_API_KEY; +const API_KEY_METHOD = process.env.SPIN_VARIABLE_API_KEY_METHOD || "authorization"; const server = createServer((req, res) => { if (req.method === "POST" && req.url.startsWith("/v1/chat/completions")) { @@ -12,6 +14,21 @@ const server = createServer((req, res) => { }); req.on("end", () => { + let apiKeyValid = false; + + if (API_KEY_METHOD === "api-key") { + apiKeyValid = req.headers["api-key"] === API_KEY; + } else { + const authHeader = req.headers["authorization"]; + apiKeyValid = authHeader && authHeader === `Bearer ${API_KEY}`; + } + + if (!apiKeyValid) { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Unauthorized" })); + return; + } + const responseChunks = [ JSON.stringify({ choices: [ diff --git a/examples/aiproxy/playwright.config.js b/examples/aiproxy/playwright.config.js index b89aa6c..714c93d 100644 --- a/examples/aiproxy/playwright.config.js +++ b/examples/aiproxy/playwright.config.js @@ -1,4 +1,18 @@ import { defineConfig, devices } from '@playwright/test'; +import fs from 'fs'; +import path from 'path'; +import dotenv from 'dotenv'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const envFilePath = path.resolve(__dirname, '.test.env'); +const envConfig = dotenv.parse(fs.readFileSync(envFilePath)); + +for (const k in envConfig) { + process.env[k] = envConfig[k]; +} /** * @see https://playwright.dev/docs/test-configuration @@ -64,25 +78,22 @@ export default defineConfig({ ], /* Run your local dev server before starting the tests */ - webServer: [{ - command: 'static-web-server -p 8080 -d web', - url: 'http://127.0.0.1:8080', - reuseExistingServer: !process.env.CI, - }, - { - command: "export $(grep -v '^#' .test.env | xargs) && spin build && spin up", - url: 'http://127.0.0.1:3000', - reuseExistingServer: !process.env.CI, - }, - { - command: "export $(grep -v '^#' .test.env | xargs) && node playwright-tests/openaimockserver.js", - url: 'http://127.0.0.1:3001', - reuseExistingServer: !process.env.CI, - }, - { - command: "export $(grep -v '^#' .test.env | xargs) && node playwright-tests/near_rpc.js", - url: 'http://127.0.0.1:14501', - reuseExistingServer: !process.env.CI, - }], + webServer: [ + { + command: "spin build && spin up", + url: 'http://127.0.0.1:3000', + reuseExistingServer: !process.env.CI, + }, + { + command: "node playwright-tests/near_rpc.js", + url: 'http://127.0.0.1:14501', + reuseExistingServer: !process.env.CI, + }, + { + command: "npx http-server ./web -p 8080", + url: 'http://127.0.0.1:8080', + reuseExistingServer: !process.env.CI, + } + ], }); diff --git a/examples/aiproxy/spin.toml b/examples/aiproxy/spin.toml index b7ae3a5..47e54d5 100644 --- a/examples/aiproxy/spin.toml +++ b/examples/aiproxy/spin.toml @@ -21,6 +21,7 @@ workdir = "openai-proxy" watch = ["src/**/*.rs", "Cargo.toml"] [variables] +openai_api_key_method = { default = "authorization" } openai_api_key = { required = true } refund_signing_key = { required = true } openai_completions_endpoint = { required = true } @@ -28,6 +29,7 @@ ft_contract_id = { required = true } rpc_url = {required = true } [component.openai-proxy.variables] +openai_api_key_method = "{{ openai_api_key_method }}" openai_api_key = "{{ openai_api_key }}" refund_signing_key = "{{ refund_signing_key }}" openai_completions_endpoint = "{{ openai_completions_endpoint }}" diff --git a/examples/aiproxy/tests/src/lib.rs b/examples/aiproxy/tests/src/lib.rs index 25df26d..bb9e039 100644 --- a/examples/aiproxy/tests/src/lib.rs +++ b/examples/aiproxy/tests/src/lib.rs @@ -14,12 +14,6 @@ use spin_test_sdk::{ const SIGNING_PUBLIC_KEY: &str = "63LxSTBisoUfp3Gu7eGY8kAVcRAmZacZjceJ2jNeGZLH"; -fn hash_string(conversation_id: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(conversation_id.as_bytes()); - hex::encode(hasher.finalize()) -} - fn handle_openai_request() { let openai_response = http::types::OutgoingResponse::new(http::types::Headers::new()); openai_response.write_body("data: {\"id\":\"chatcmpl-AMaFCyZmtLWFTUrXg0ZyEI9gz0wbj\",\"object\":\"chat.completion.chunk\",\"created\":1729945902,\"model\":\"gpt-4o-2024-08-06\",\"system_fingerprint\":\"fp_72bbfa6014\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"chatcmpl-AMaFCyZmtLWFTUrXg0ZyEI9gz0wbj\",\"object\":\"chat.completion.chunk\",\"created\":1729945902,\"model\":\"gpt-4o-2024-08-06\",\"system_fingerprint\":\"fp_72bbfa6014\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\n @@ -35,6 +29,18 @@ data: {\"id\":\"chatcmpl-AMaFCyZmtLWFTUrXg0ZyEI9gz0wbj\",\"object\":\"chat.compl ); } +fn handle_openai_request_with_error() { + let openai_response = http::types::OutgoingResponse::new(http::types::Headers::new()); + openai_response.set_status_code(401).unwrap(); + + openai_response.write_body("{ \"statusCode\": 401, \"message\": \"Unauthorized. Access token is missing, invalid, audience is incorrect (https://cognitiveservices.azure.com), or have expired.\" }".as_bytes()); + + http_handler::set_response( + "https://api.openai.com/v1/chat/completions", + http_handler::ResponseHandler::Response(openai_response), + ); +} + fn set_variables() { spin_test_virt::variables::set( "refund_signing_key", @@ -119,6 +125,60 @@ fn openai_request() { ); } +#[spin_test] +fn handle_openai_request_error() { + set_variables(); + handle_openai_request_with_error(); + + let conversation_info = json!({"receiver_id":"aiuser.testnet","amount":"256000"}) + .to_string() + .as_bytes() + .to_vec(); + let response = http::types::OutgoingResponse::new(http::types::Headers::new()); + response.write_body( + json!({ + "jsonrpc": "2.0", + "result": { + "result": conversation_info, + "logs": [], + "block_height": 17817336, + "block_hash": "4qkA4sUUG8opjH5Q9bL5mWJTnfR4ech879Db1BZXbx6P" + }, + "id": "dontcare" + }) + .to_string() + .as_bytes(), + ); + http_handler::set_response( + "https://rpc.mainnet.near.org", + http_handler::ResponseHandler::Response(response), + ); + + let request = http::types::OutgoingRequest::new(http::types::Headers::new()); + request.set_method(&http::types::Method::Post).unwrap(); + request.set_path_with_query(Some("/proxy-openai")).unwrap(); + request.body().unwrap().write_bytes(json!( + { + "conversation_id": "aiuser.testnet_1729432017818", + "messages":[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"hello"}] + }).to_string().as_bytes()); + let response = spin_test_sdk::perform_request(request); + + assert_ne!(response.status(), 200); + let store = spin_test_virt::key_value::Store::open("default"); + let stored_conversation_balance: serde_json::Value = + serde_json::from_slice(&store.get("aiuser.testnet_1729432017818").unwrap()[..]).unwrap(); + + assert_eq!( + u64::from_str_radix(stored_conversation_balance["amount"].as_str().unwrap(), 10).unwrap(), + (256000) as u64 + ); + assert_eq!( + stored_conversation_balance["locked_for_ongoing_request"], + false + ); +} + #[spin_test] fn openai_request_unknown_conversation() { set_variables(); diff --git a/examples/fungibletoken/e2e/aiconversation.js b/examples/fungibletoken/e2e/aiconversation.js index a9e6bbb..2387ce2 100644 --- a/examples/fungibletoken/e2e/aiconversation.js +++ b/examples/fungibletoken/e2e/aiconversation.js @@ -31,7 +31,7 @@ export function refund_unspent() { const conversation_data = JSON.parse(env.get_data(conversation_id)); - if (BigInt(conversation_data.amount) > BigInt(refund_amount)) { + if (BigInt(conversation_data.amount) >= BigInt(refund_amount)) { env.clear_data(conversation_id); env.ft_transfer_internal(env.current_account_id(), receiver_id, refund_amount); print(`refunded ${refund_amount} to ${receiver_id}`); diff --git a/package.json b/package.json index c5c5030..f47c47f 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,8 @@ "@types/node": "^22.10.2", "@web/rollup-plugin-html": "^2.3.0", "chai": "^5.1.1", + "dotenv": "^16.4.7", "http-server": "^14.1.1", - "near-workspaces": "4.0.0", "rollup": "^4.29.1", "rollup-plugin-terser": "^7.0.2" diff --git a/yarn.lock b/yarn.lock index 03f3d21..a88c48e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -727,6 +727,11 @@ dot-case@^3.0.4: no-case "^3.0.4" tslib "^2.0.3" +dotenv@^16.4.7: + version "16.4.7" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.7.tgz#0e20c5b82950140aa99be360a8a5f52335f53c26" + integrity sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ== + dunder-proto@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" @@ -1731,16 +1736,7 @@ source-map@^0.6.0, source-map@~0.6.0: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -1758,14 +1754,7 @@ string-width@^5.0.1, string-width@^5.1.2: emoji-regex "^9.2.2" strip-ansi "^7.0.1" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==