Last active
September 1, 2026 04:33
-
-
Save fourlexboehm/a60e4ef9306744483731cd176bd02e9f to your computer and use it in GitHub Desktop.
Minimal CLI Agent in C
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // This is an entire CLI agent, it can do everything Claude code can do, using raw mode to support pasting, backspace, | |
| // escape to cancel agents turn, and multiline prompts with Shift + enter, | |
| // but with 2MB of RAM, neglibible CPU usage and one small C file. | |
| // It is a single C file for the openai responses api, with no dependency other than libcurl, and an | |
| // OPENAI_API_KEY env var. | |
| // compile with `cc -lcurl -o cgent this_file.c` then install where you please. | |
| // AI USAGE - This code was written without AI agents, largely to maintain my c skills, as such it is likely to have | |
| // memory safety bugs. | |
| #include <ctype.h> | |
| #include <curl/curl.h> | |
| #include <stdio.h> | |
| #include <string.h> | |
| #include <stdlib.h> | |
| #include <unistd.h> | |
| #include <termios.h> | |
| #include <stdbool.h> | |
| #define BUF_SIZE 65536 | |
| #define INPUT_SIZE (1024 * 1024 + 4096) | |
| // A terminal read returns a keystroke or a paste burst, never a whole prompt. | |
| // take_prompt loops until stdin is drained and accumulates into prompt_buffer, | |
| // so this only bounds a single read(2). | |
| #define READ_SIZE 4096 | |
| // Escaping can double every byte, so this bounds a prompt of PROMPT_SIZE / 2. | |
| // It is never written past what is typed, so untouched pages stay unmapped. | |
| #define PROMPT_SIZE (1024 * 1024) | |
| #define CARRY_SIZE 65536 | |
| #define RESPONSE_ID_LEN 55 | |
| // Longest marker we search for; a chunk's final MARKER_MAX bytes are carried | |
| // over, so a marker split across chunks is seen on the next pass. | |
| #define MARKER_MAX 40 | |
| static const char MODEL[] = "gpt-5.6-luna"; | |
| // Interpolated raw, so no quotes or newlines. Sent every request: previous_response_id | |
| // does not carry instructions over, and a prefix that only sometimes holds them misses. | |
| static const char INSTRUCTIONS[] = | |
| "You are an AI agent, you have access to one tool, bash, use it in this " | |
| "directory to see what's here yourself to answer solve my problems."; | |
| static const char TOOLS_JSON[] = | |
| ",\"tools\":[{\"type\":\"function\",\"name\":\"bash\"," | |
| "\"description\":\"Run a bash command, returns stdout and stderr.\"," | |
| "\"parameters\":{\"type\":\"object\",\"properties\":{\"command\":{\"type\":\"string\"}}," | |
| "\"required\":[\"command\"],\"additionalProperties\":false},\"strict\":true}]"; | |
| static int tool_call_count; | |
| static int input_len; | |
| static char input_buffer[INPUT_SIZE]; | |
| // The response body is parsed in place as it arrives and never accumulated. Only | |
| // the tail that might hold a marker split across a chunk boundary, or a tool call | |
| // that has not fully arrived, is carried over to the next chunk. | |
| static char carry[CARRY_SIZE]; | |
| static int carry_len; | |
| static bool in_text, text_esc, saw_text; | |
| // The id arrives mid-stream, but chaining from a cancelled turn leaves a function_call | |
| // with no output, which the next request rejects. Committed only on completion. | |
| static char previous_response_id[RESPONSE_ID_LEN + 1], pending_response_id[RESPONSE_ID_LEN + 1]; | |
| struct termios orig_termios; | |
| void wout(const char *str, int len) { | |
| write(STDOUT_FILENO, str, len); | |
| } | |
| static void erase_previous(void) { | |
| static const char erase_previous[] = "\x1b[1D \x1b[1D"; | |
| wout(erase_previous, sizeof(erase_previous) - 1); | |
| } | |
| static void safe_exit(void) { | |
| tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig_termios); | |
| } | |
| static void enable_raw_mode(void) { | |
| tcgetattr(STDIN_FILENO, &orig_termios); | |
| struct termios raw = orig_termios; | |
| raw.c_lflag &= ~(ECHO | ICANON); | |
| raw.c_iflag &= ~ICRNL; | |
| raw.c_cc[VMIN] = 1; | |
| raw.c_cc[VTIME] = 0; | |
| atexit(safe_exit); | |
| tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw); | |
| } | |
| /* Return the number of characters on the last (logical) line. The prompt | |
| * buffer contains a JSON string body, so a newline is stored as "\\n" and a | |
| * quote/backslash may occupy two bytes. */ | |
| static int get_line_index(const char *prompt_buffer, const char *prompt_end) { | |
| int line_index = 0; | |
| bool first_line = true; | |
| while (prompt_buffer < prompt_end) { | |
| if (*prompt_buffer == '\\' && prompt_buffer + 1 < prompt_end) { | |
| if (prompt_buffer[1] == 'n') { | |
| line_index = 0; | |
| prompt_buffer += 2; | |
| first_line = false; | |
| continue; | |
| } | |
| if (prompt_buffer[1] == '"' || prompt_buffer[1] == '\\') { | |
| line_index++; | |
| prompt_buffer += 2; | |
| continue; | |
| } | |
| } | |
| line_index++; | |
| prompt_buffer++; | |
| } | |
| return line_index + first_line; | |
| } | |
| static void take_prompt(char *read_buffer, char *prompt_buffer) { | |
| int line_index = 0; | |
| char *prompt_pos = prompt_buffer; | |
| *prompt_pos = 0; | |
| for (int bytes_read; | |
| (bytes_read = read(STDIN_FILENO, read_buffer, READ_SIZE - 1)) >= 0;) { | |
| // read_buffer is compared as a C string below, and is not zeroed between | |
| // reads, so terminate what actually arrived. | |
| read_buffer[bytes_read] = 0; | |
| for (int i = 0; i < bytes_read; i++) { | |
| char c = read_buffer[i]; | |
| // No token appends more than two bytes plus the terminator. Once | |
| // full, drop input rather than run off the end; editing keys still | |
| // work, so the prompt can be cut back down. | |
| bool full = prompt_pos - prompt_buffer + 3 > PROMPT_SIZE; | |
| if (!iscntrl(c) && read_buffer[0] != '\x1b' ) { | |
| if (full) continue; | |
| wout(&c, 1); | |
| switch (c) { | |
| // The buffer is interpolated straight into the request as a | |
| // JSON string, so escape here as newlines already are. | |
| case '"': | |
| case '\\': | |
| *prompt_pos++ = '\\'; | |
| } | |
| *prompt_pos++ = c; | |
| line_index++; | |
| } else { | |
| switch (c) { | |
| // backspace | |
| case 127: | |
| /* A newline is two bytes in the JSON buffer, but one | |
| * terminal line break. Keep the buffer and cursor in | |
| * sync when deleting it. */ | |
| if (prompt_pos == prompt_buffer) | |
| break; | |
| if (line_index == 0) { | |
| /* At column zero the previous token is an escaped | |
| * newline. The empty-buffer check prevents moving | |
| * above the first line. */ | |
| if (prompt_pos - prompt_buffer < 2 || | |
| prompt_pos[-2] != '\\' || prompt_pos[-1] != 'n') | |
| break; | |
| prompt_pos -= 2; | |
| *prompt_pos = 0; | |
| line_index = get_line_index(prompt_buffer, prompt_pos); | |
| /* Cursor is at the start of the current line. */ | |
| printf("\x1b[1F"); | |
| if (line_index > 0) | |
| printf("\x1b[%dC", line_index); | |
| fflush(stdout); | |
| break; | |
| } | |
| /* Quotes and backslashes have an extra JSON byte. */ | |
| if (prompt_pos - prompt_buffer >= 2 && | |
| prompt_pos[-2] == '\\' && | |
| (prompt_pos[-1] == '"' || prompt_pos[-1] == '\\')) | |
| prompt_pos -= 2; | |
| else | |
| prompt_pos--; | |
| *prompt_pos = 0; | |
| erase_previous(); | |
| line_index--; | |
| break; | |
| case '\r': | |
| //buffer | |
| line_index = 0; | |
| wout("\n", 1); | |
| if (bytes_read == i + 1) { | |
| *prompt_pos = 0; | |
| return; | |
| } | |
| if (full) break; | |
| *prompt_pos++ = '\\'; | |
| *prompt_pos++ = 'n'; | |
| break; | |
| case '\n': | |
| //buffer | |
| line_index = 0; | |
| wout("\n", 1); | |
| if (full) break; | |
| *prompt_pos++ = '\\'; | |
| *prompt_pos++ = 'n'; | |
| break; | |
| default: | |
| // Handle escape sequences, some terminals send this for shift + enter | |
| // Shift + Enter | |
| if (!strcmp(read_buffer, "\x1b[27;2;13~")) { | |
| if (!full) { | |
| *prompt_pos++ = '\\'; | |
| *prompt_pos++ = 'n'; | |
| } | |
| wout("\n", 1); | |
| line_index = 0; | |
| goto buffer_parsed; | |
| } | |
| printf("%d\n", c); | |
| break; | |
| } | |
| } | |
| } | |
| buffer_parsed:; | |
| } | |
| // read(2) failed; the caller still needs a terminated buffer. | |
| *prompt_pos = 0; | |
| } | |
| static void wout_codepoint(unsigned int cp) { | |
| char b[4]; | |
| int n = 0; | |
| if (cp < 0x80) b[n++] = cp; | |
| else if (cp < 0x800) { | |
| b[n++] = 0xC0 | cp >> 6; | |
| b[n++] = 0x80 | (cp & 0x3F); | |
| } else if (cp < 0x10000) { | |
| b[n++] = 0xE0 | cp >> 12; | |
| b[n++] = 0x80 | (cp >> 6 & 0x3F); | |
| b[n++] = 0x80 | (cp & 0x3F); | |
| } else { | |
| b[n++] = 0xF0 | cp >> 18; | |
| b[n++] = 0x80 | (cp >> 12 & 0x3F); | |
| b[n++] = 0x80 | (cp >> 6 & 0x3F); | |
| b[n++] = 0x80 | (cp & 0x3F); | |
| } | |
| wout(b, n); | |
| } | |
| static enum { JSON_TEXT, JSON_ESC, JSON_HEX } unescape_state; | |
| static unsigned int unescape_cp, unescape_high; | |
| static int unescape_hexlen; | |
| static void reset_json_unescape(void) { | |
| unescape_state = JSON_TEXT; | |
| unescape_cp = unescape_high = 0; | |
| unescape_hexlen = 0; | |
| } | |
| // Unescapes a JSON string body straight to stdout. Holds only the few bytes of | |
| // escape state needed to resume, so it can be fed arbitrary chunk boundaries. | |
| static void print_json_unescaped(const char *src, int n) { | |
| for (int i = 0; i < n; i++) { | |
| char c = src[i]; | |
| switch (unescape_state) { | |
| case JSON_TEXT: | |
| if (c == '\\') unescape_state = JSON_ESC; | |
| else wout(&c, 1); | |
| break; | |
| case JSON_ESC: | |
| unescape_state = JSON_TEXT; | |
| switch (c) { | |
| case 'n': wout("\n", 1); break; | |
| case 't': wout("\t", 1); break; | |
| case 'r': wout("\r", 1); break; | |
| case 'b': wout("\b", 1); break; | |
| case 'f': wout("\f", 1); break; | |
| case 'u': | |
| unescape_cp = 0; | |
| unescape_hexlen = 0; | |
| unescape_state = JSON_HEX; | |
| break; | |
| default: wout(&c, 1); | |
| } | |
| break; | |
| case JSON_HEX: | |
| unescape_cp = unescape_cp * 16 + | |
| (c <= '9' ? c - '0' : (c | 32) - 'a' + 10); | |
| if (++unescape_hexlen < 4) break; | |
| unescape_state = JSON_TEXT; | |
| if (unescape_cp >= 0xD800 && unescape_cp < 0xDC00) { | |
| unescape_high = unescape_cp; | |
| break; | |
| } | |
| if (unescape_high && unescape_cp >= 0xDC00 && unescape_cp < 0xE000) | |
| unescape_cp = 0x10000 + ((unescape_high - 0xD800) << 10) + | |
| (unescape_cp - 0xDC00); | |
| unescape_high = 0; | |
| wout_codepoint(unescape_cp); | |
| } | |
| } | |
| } | |
| // Appends src to dst as an escaped JSON string body, returns the new length. | |
| static int json_escape(char *dst, int len, const char *src) { | |
| for (; *src && len + 2 < INPUT_SIZE; src++) { | |
| switch (*src) { | |
| case '"': case '\\': dst[len++] = '\\'; dst[len++] = *src; break; | |
| case '\n': dst[len++] = '\\'; dst[len++] = 'n'; break; | |
| case '\t': dst[len++] = '\\'; dst[len++] = 't'; break; | |
| default: if ((unsigned char)*src >= 0x20) dst[len++] = *src; | |
| } | |
| } | |
| return len; | |
| } | |
| static void feed(char *chunk, int len, bool final); | |
| static void run_agent(char *prompt, CURL *curl, CURL *multi) { | |
| static struct curl_waitfd input = { | |
| .fd = STDIN_FILENO, | |
| .events = CURL_WAIT_POLLIN, | |
| .revents = 0 | |
| }; | |
| struct termios raw_termios; | |
| tcgetattr(STDIN_FILENO, &raw_termios); | |
| char animation[] = "\x1b[1D!"; | |
| char c = 0; | |
| raw_termios.c_cc[VMIN] = 0; | |
| raw_termios.c_cc[VTIME] = 1; | |
| tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw_termios); | |
| snprintf(input_buffer, INPUT_SIZE, "\"%s\"", prompt); | |
| do { | |
| curl_multi_add_handle(multi, curl); | |
| char *previous_json = NULL; | |
| if (*previous_response_id) | |
| asprintf(&previous_json, ", \"previous_response_id\":\"%s\"", previous_response_id); | |
| // gpt-5.6 needs prompt_cache_key for reliable matching: without it an identical | |
| // prefix routes by hash alone and lands elsewhere each turn. Every turn of a | |
| // session shares the prefix, so one key per process; a reused pid costs a miss. | |
| char *json; | |
| asprintf( | |
| &json, | |
| "{\"model\":\"%s\",\"instructions\":\"%s\",\"prompt_cache_key\":\"cgent:%d\"" | |
| ",\"input\":%s%s%s}", | |
| MODEL, INSTRUCTIONS, (int)getpid(), | |
| input_buffer, | |
| TOOLS_JSON, | |
| previous_json ? previous_json : "" | |
| ); | |
| //printf("%s\n", json); | |
| curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json); | |
| curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, strlen(json)); | |
| tool_call_count = carry_len = *pending_response_id = 0; | |
| in_text = text_esc = saw_text = false; | |
| int still_running; | |
| do { | |
| CURLMcode mc = curl_multi_perform(multi, &still_running); | |
| if (!mc && still_running && c != 27) { | |
| // extra_nfds must be 1, otherwise stdin is never polled, revents | |
| // is never set, and ESC cannot cancel a stalled request. | |
| mc = curl_multi_poll(multi, &input, 1, 80, NULL); | |
| } | |
| if (input.revents & CURL_WAIT_POLLIN) { | |
| read(STDIN_FILENO, &c, 1); | |
| input.revents = 0; | |
| } | |
| wout(animation, sizeof(animation) - 1); | |
| if (animation[4] == '/') animation[4] = '!'; | |
| animation[4]++; | |
| } while (still_running && c !=27); | |
| erase_previous(); | |
| // Drain the multi queue before removing the handle, otherwise a transport | |
| // failure is silently indistinguishable from an empty reply. | |
| CURLMsg *msg; | |
| for (int queued; (msg = curl_multi_info_read(multi, &queued));) | |
| if (msg->msg == CURLMSG_DONE && msg->data.result != CURLE_OK) | |
| printf("\x1b[31mcurl: %s\x1b[0m\n", | |
| curl_easy_strerror(msg->data.result)); | |
| if (c != 27) { | |
| long code = 0; | |
| curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code); | |
| if (code && code != 200) { | |
| printf("\x1b[31mHTTP %ld\x1b[0m\n%s\n", code, json); | |
| exit(1); | |
| } | |
| feed(carry, 0, true); | |
| // Per iteration, not per turn: the tool loop attaches its output to this id. | |
| if (*pending_response_id) strcpy(previous_response_id, pending_response_id); | |
| if (!saw_text && !tool_call_count) | |
| printf("\x1b[31m[no text or tool call in response]\x1b[0m\n"); | |
| fflush(stdout); | |
| } | |
| curl_multi_remove_handle(multi, curl); | |
| free(json); | |
| free(previous_json); | |
| } while (tool_call_count && c != 27); | |
| raw_termios.c_cc[VMIN] = 1; | |
| raw_termios.c_cc[VTIME] = 0; | |
| tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw_termios); | |
| } | |
| static void run_repl(CURL *curl, CURL *multi) { | |
| char *read_buffer = malloc(READ_SIZE); | |
| char *prompt_buffer = malloc(PROMPT_SIZE); | |
| for(;;) { | |
| wout(">", 1); | |
| take_prompt(read_buffer, prompt_buffer); | |
| run_agent(prompt_buffer, curl, multi); | |
| } | |
| free(read_buffer); | |
| free(prompt_buffer); | |
| } | |
| static void run_tool(char *id, char *id_end, char *src, char *end) { | |
| // `exec` with only redirections applies them to the whole shell, so this holds | |
| // however the command is structured. Detaching stdin is what matters: popen | |
| // inherits our raw-mode terminal, so anything that reads stdin (or prompts) | |
| // blocks the agent forever. 2>&1 also makes this match the tool description. | |
| static const char prefix[] = "exec </dev/null 2>&1\n"; | |
| static const int prefix_len = sizeof prefix - 1; | |
| // The prefix is reserved at the head of the buffer and the command parsed in | |
| // directly after it, so the shell gets one buffer with no second copy. | |
| static char shell_command[BUF_SIZE], output[BUF_SIZE]; | |
| memcpy(shell_command, prefix, prefix_len); | |
| char *command = shell_command + prefix_len; | |
| int n = 0; | |
| while (src + 2 < end && !(src[0] == '\\' && src[1] == '"' && src[2] == '}') && | |
| prefix_len + n + 1 < BUF_SIZE) { | |
| char c = *src++; | |
| if (c == '\\' && src < end && *src == '\\') { | |
| src++; | |
| c = *src++; | |
| if (c == '\\' && src < end) c = *src++; | |
| else if (c == 'n') c = '\n'; | |
| else if (c == 't') c = '\t'; | |
| else if (c == 'r') c = '\r'; | |
| } | |
| command[n++] = c; | |
| } | |
| command[n] = 0; | |
| erase_previous(); | |
| printf("\x1b[90m$ %s\x1b[0m\n", command); | |
| fflush(stdout); | |
| FILE *pipe = popen(shell_command, "r"); | |
| int out_len = 0; | |
| bool truncated = false; | |
| if (pipe) { | |
| for (;;) { | |
| int space = (int)sizeof(output) - 1 - out_len; | |
| if (space > 0) { | |
| int r = fread(output + out_len, 1, space, pipe); | |
| if (r <= 0) break; | |
| out_len += r; | |
| } else { | |
| // Keep draining so the child is not killed mid-write by SIGPIPE. | |
| char sink[4096]; | |
| if (fread(sink, 1, sizeof sink, pipe) <= 0) break; | |
| truncated = true; | |
| } | |
| } | |
| pclose(pipe); | |
| } | |
| output[out_len] = 0; | |
| wout(output, out_len); | |
| if (truncated) wout("\n[output truncated]\n", 20); | |
| if (!tool_call_count) { | |
| input_buffer[0] = '['; | |
| input_len = 1; | |
| } | |
| input_len += snprintf(input_buffer + input_len, INPUT_SIZE - input_len, | |
| "%s{\"type\":\"function_call_output\",\"call_id\":\"%.*s\",\"output\":\"", | |
| tool_call_count++ ? "," : "", (int)(id_end - id), id); | |
| input_len = json_escape(input_buffer, input_len, output); | |
| if (truncated) | |
| input_len = json_escape(input_buffer, input_len, "\n[output truncated]"); | |
| input_len += snprintf(input_buffer + input_len, INPUT_SIZE - input_len, "\"}"); | |
| snprintf(input_buffer + input_len, INPUT_SIZE - input_len, "]"); | |
| } | |
| // Finds the unescaped closing quote of a JSON string body. text_esc carries the | |
| // "previous byte was a backslash" state across calls so a split escape sequence | |
| // is not mistaken for the terminator. | |
| static char *find_string_end(char *src, int n) { | |
| for (int i = 0; i < n; i++) { | |
| if (text_esc) { text_esc = false; continue; } | |
| if (src[i] == '\\') { text_esc = true; continue; } | |
| if (src[i] == '"') return src + i; | |
| } | |
| return NULL; | |
| } | |
| // Parses src[0..n) in place and returns the offset of the first byte that could | |
| // not be consumed yet; the caller carries [ret, n) into the next chunk. With | |
| // final set, everything is consumed. | |
| static int parse_chunk(char *src, int n, bool final) { | |
| int cur = 0; | |
| for (;;) { | |
| char *base = src + cur; | |
| char *end = src + n; | |
| int avail = n - cur; | |
| if (avail <= 0) return n; | |
| if (in_text) { | |
| // find_string_end carries its escape state, so text streams out with | |
| // no lookahead at all. | |
| char *stop = find_string_end(base, avail); | |
| print_json_unescaped(base, (stop ? stop : end) - base); | |
| if (!stop) return n; | |
| wout("\n", 1); | |
| in_text = false; | |
| cur = (stop - src) + 1; | |
| continue; | |
| } | |
| char *call = strnstr(base, "\"type\": \"function_call\"", avail); | |
| char *rid = strnstr(base, "\"id\": \"resp_", avail); | |
| char *text = strnstr(base, "\"text\": \"", avail); | |
| char *first = NULL; | |
| int kind = 0; | |
| if (call) { first = call; kind = 1; } | |
| if (rid && (!first || rid < first)) { first = rid; kind = 2; } | |
| if (text && (!first || text < first)) { first = text; kind = 3; } | |
| // No marker here, so one can only have been split across the boundary. | |
| // Everything before the last MARKER_MAX bytes is provably marker-free. | |
| if (!first) { | |
| if (final) return n; | |
| return avail > MARKER_MAX ? n - MARKER_MAX : cur; | |
| } | |
| if (kind == 1) { | |
| // Bound the field search to this output item so a truncated item | |
| // cannot borrow the next one's call_id. | |
| char *item_end = strnstr(first + 1, "\"type\": \"function_call\"", | |
| end - (first + 1)); | |
| if (!item_end) item_end = end; | |
| char *id = strnstr(first, "\"call_id\": \"", item_end - first); | |
| char *command = strnstr(first, "\"arguments\": \"{\\\"command\\\":\\\"", | |
| item_end - first); | |
| char *id_end = NULL, *command_end = NULL; | |
| if (id && command) { | |
| id += sizeof("\"call_id\": \"") - 1; | |
| command += sizeof("\"arguments\": \"{\\\"command\\\":\\\"") - 1; | |
| id_end = memchr(id, '"', item_end - id); | |
| command_end = strnstr(command, "\\\"}", item_end - command); | |
| } | |
| // Incomplete item: carry it whole and retry once the rest lands. | |
| if (!id_end || !command_end) | |
| return final ? n : first - src; | |
| run_tool(id, id_end, command, command_end + 3); | |
| cur = (command_end - src) + 3; | |
| continue; | |
| } | |
| if (kind == 2) { | |
| char *id = first + sizeof("\"id\": \"") - 1; | |
| // Wait until the whole id has arrived, otherwise the copy picks up | |
| // bytes that are not part of it yet. | |
| if (end - id < RESPONSE_ID_LEN) | |
| return final ? n : first - src; | |
| strncpy(pending_response_id, id, RESPONSE_ID_LEN); | |
| cur = (id - src) + RESPONSE_ID_LEN; | |
| continue; | |
| } | |
| // kind == 3: start of an output_text value. | |
| cur = (first - src) + sizeof("\"text\": \"") - 1; | |
| in_text = true; | |
| saw_text = true; | |
| text_esc = false; | |
| reset_json_unescape(); | |
| erase_previous(); | |
| } | |
| } | |
| // Parses a chunk together with whatever the last one carried over. Only the | |
| // carry is ever copied; the body itself is parsed straight out of curl's buffer. | |
| static void feed(char *chunk, int len, bool final) { | |
| char *src = chunk; | |
| int n = len; | |
| if (carry_len) { | |
| if (carry_len + len > CARRY_SIZE) { | |
| printf("\x1b[31m[tool call exceeds %d bytes]\x1b[0m\n", CARRY_SIZE); | |
| exit(1); | |
| } | |
| memcpy(carry + carry_len, chunk, len); | |
| src = carry; | |
| n = carry_len + len; | |
| } | |
| int keep = n - parse_chunk(src, n, final); | |
| if (keep) memmove(carry, src + n - keep, keep); | |
| carry_len = keep; | |
| } | |
| static size_t write_mem_callback(void *contents, size_t size, size_t nmemb, void *userp) { | |
| (void)userp; | |
| int len = size * nmemb; | |
| feed(contents, len, false); | |
| return len; | |
| } | |
| int main(int argc, char **argv) { | |
| (void)argc; | |
| (void)argv; | |
| CURL *multi = curl_multi_init(); | |
| CURL *curl = curl_easy_init(); | |
| if (!curl) { | |
| curl_easy_cleanup(curl); | |
| exit(-1); | |
| } | |
| curl_easy_setopt(curl, CURLOPT_URL, "https://api.openai.com/v1/responses"); | |
| struct curl_slist *list = NULL; | |
| char *api_key = getenv("OPENAI_API_KEY"); | |
| if (api_key == NULL) return -1; | |
| char auth_header[] = "Authorization: Bearer \0 "; | |
| strcat(auth_header, api_key); | |
| list = curl_slist_append( | |
| list, | |
| auth_header | |
| ); | |
| list = curl_slist_append(list, "Content-Type: application/json"); | |
| curl_easy_setopt(curl, CURLOPT_HTTPHEADER, list); | |
| curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_mem_callback); | |
| curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 20L); | |
| // No overall timeout: a long generation is legitimate. Give up only once the | |
| // connection has stopped delivering anything at all. | |
| curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1L); | |
| curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 120L); | |
| enable_raw_mode(); | |
| run_repl(curl, multi); | |
| curl_easy_cleanup(curl); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment