X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/cb514b662bcf9d90b2cf02789c989f777d774d3f..59948b44c0fe2165b90e3aa0d18646f6c3d74af3:/model.go?ds=inline diff --git a/model.go b/model.go index 61dbc58..cc77f9a 100644 --- a/model.go +++ b/model.go @@ -41,9 +41,9 @@ const ( var errModelCallLimit = errors.New("call limit reached") // modelEndpoint returns the URL and the authorizer of the endpoint in config. -// The ChatGPT backend uses the token file in dir. The OpenAI API uses the key in -// OPENAI_API_KEY, and there is no fallback from one to the other. All clients -// share the authorizer, so one refresh runs at a time. +// The ChatGPT backend uses the token file in dir. The OpenAI API uses the key +// in OPENAI_API_KEY, and there is no fallback from one to the other. All +// clients share the authorizer, so one refresh runs at a time. func modelEndpoint(config Config, dir string) (string, ModelAuthorizer, error) { if config.Endpoint == configEndpointAPI { key := os.Getenv("OPENAI_API_KEY") @@ -73,22 +73,22 @@ func newModelClient(url string, auth ModelAuthorizer, cacheKey, usagePath string // modelUserMessage returns a user input item with one text part for each text. func modelUserMessage(texts ...string) any { - type part struct { - Type string `json:"type"` - Text string `json:"text"` - } - - content := make([]part, len(texts)) + content := make([]ModelInputPart, len(texts)) for n, text := range texts { - content[n] = part{ + content[n] = ModelInputPart{ Type: "input_text", Text: text, } } + return modelUserParts(content) +} + +// modelUserParts keeps text and image parts in one user message. +func modelUserParts(content []ModelInputPart) any { return struct { - Role string `json:"role"` - Content []part `json:"content"` + Role string `json:"role"` + Content []ModelInputPart `json:"content"` }{ Role: "user", Content: content, @@ -98,8 +98,10 @@ func modelUserMessage(texts ...string) any { // modelOutputText returns the text of the message items of output. func modelOutputText(output []json.RawMessage) (string, error) { var text strings.Builder + for _, raw := range output { var item ModelOutputItem + err := json.Unmarshal(raw, &item) if err != nil { return "", fmt.Errorf("decode output item: %w", err) @@ -131,6 +133,7 @@ type ModelAPIKey string // Authorize sets the key as the bearer token. func (k ModelAPIKey) Authorize(ctx context.Context, header http.Header) error { header.Set("Authorization", "Bearer "+string(k)) + return ctx.Err() } @@ -168,6 +171,16 @@ func (c *ModelClient) LimitReached() bool { return c.limit > 0 && c.calls.Load() >= c.limit } +// CallLimit returns the configured call limit, or zero for no limit. +func (c *ModelClient) CallLimit() int64 { + return c.limit +} + +// UsagePath returns the file that records the calls of this client. +func (c *ModelClient) UsagePath() string { + return c.usage.path +} + // Create sends the call and reads its event stream to the end. Over the call // limit it sends nothing and returns errModelCallLimit. func (c *ModelClient) Create(ctx context.Context, request ModelRequest) (ModelResponse, error) { @@ -204,6 +217,7 @@ func (c *ModelClient) Create(ctx context.Context, request ModelRequest) (ModelRe httpRequest.Header.Set("Content-Type", "application/json") httpRequest.Header.Set("Accept", "text/event-stream") + err = c.auth.Authorize(ctx, httpRequest.Header) if err != nil { return ModelResponse{}, fmt.Errorf("authorize model call: %w", err) @@ -307,6 +321,13 @@ type ModelOutputItem struct { } `json:"content"` } +// ModelInputPart is a text or image part in a user message or tool result. +type ModelInputPart struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ImageURL string `json:"image_url,omitempty"` +} + // ModelRequest is one model call. Input holds the input items in order, such as // modelUserMessage values or output items of an earlier response. Node names // the node that a compaction builds, or is nil. Turn names the request of a @@ -424,6 +445,7 @@ type ModelWireError struct { // without the turn state. func modelQuotaHeaders(header http.Header) map[string]string { var quota map[string]string + for name, values := range header { if !strings.HasPrefix(name, modelQuotaPrefix) || name == modelTurnStateHeader || len(values) == 0 { continue @@ -444,8 +466,11 @@ func modelQuotaHeaders(header http.Header) map[string]string { // the completed response when the stream sent none. func modelReadStream(stream io.Reader) (ModelResponse, error) { var response ModelResponse + reader := bufio.NewReader(stream) + var data bytes.Buffer + for { line, err := reader.ReadBytes('\n') if err != nil && !errors.Is(err, io.EOF) { @@ -462,6 +487,7 @@ func modelReadStream(stream io.Reader) (ModelResponse, error) { } else if data.Len() > 0 { done, eventErr := modelReadEvent(&response, data.Bytes()) data.Reset() + if eventErr != nil || done { return response, eventErr } @@ -477,6 +503,7 @@ func modelReadStream(stream io.Reader) (ModelResponse, error) { // stream. func modelReadEvent(response *ModelResponse, data []byte) (bool, error) { var event ModelStreamEvent + err := json.Unmarshal(data, &event) if err != nil { return true, fmt.Errorf("decode stream event: %w", err)