From 29b0faf61bc8588a3c7e33980ffcdabd2497ad7d Mon Sep 17 00:00:00 2001 From: "STARAI\\Star" Date: Sat, 7 Sep 2024 23:14:12 +0800 Subject: [PATCH] 1 --- LICENSE | 9 + aigc.go | 83 +++++ chat.go | 140 ++++++++ config.go | 60 ++++ go.mod | 17 + zhipu/CHANGELOG.md | 108 ++++++ zhipu/LICENSE | 21 ++ zhipu/README.md | 280 ++++++++++++++++ zhipu/README.zh.md | 278 ++++++++++++++++ zhipu/async_result.go | 63 ++++ zhipu/async_result_test.go | 1 + zhipu/batch.go | 258 +++++++++++++++ zhipu/batch_support.go | 63 ++++ zhipu/batch_support_test.go | 73 +++++ zhipu/batch_test.go | 59 ++++ zhipu/chat_completion.go | 577 +++++++++++++++++++++++++++++++++ zhipu/chat_completion_test.go | 251 ++++++++++++++ zhipu/client.go | 291 +++++++++++++++++ zhipu/client_test.go | 17 + zhipu/cog.toml | 25 ++ zhipu/embedding.go | 87 +++++ zhipu/embedding_test.go | 21 ++ zhipu/error.go | 58 ++++ zhipu/error_test.go | 38 +++ zhipu/file.go | 541 +++++++++++++++++++++++++++++++ zhipu/file_test.go | 71 ++++ zhipu/fine_tune.go | 456 ++++++++++++++++++++++++++ zhipu/fine_tune_test.go | 3 + zhipu/image_generation.go | 110 +++++++ zhipu/image_generation_test.go | 21 ++ zhipu/knowledge.go | 299 +++++++++++++++++ zhipu/knowledge_test.go | 50 +++ zhipu/string_or.go | 54 +++ zhipu/string_or_test.go | 37 +++ zhipu/testdata/test-file.jsonl | 3 + zhipu/testdata/test-file.txt | 1 + zhipu/util.go | 22 ++ zhipu/util_test.go | 3 + zhipu/video_generation.go | 125 +++++++ zhipu/video_generation_test.go | 38 +++ zhipu/wechat-donation.png | Bin 0 -> 46799 bytes 41 files changed, 4712 insertions(+) create mode 100644 LICENSE create mode 100644 aigc.go create mode 100644 chat.go create mode 100644 config.go create mode 100644 go.mod create mode 100644 zhipu/CHANGELOG.md create mode 100644 zhipu/LICENSE create mode 100644 zhipu/README.md create mode 100644 zhipu/README.zh.md create mode 100644 zhipu/async_result.go create mode 100644 zhipu/async_result_test.go create mode 100644 zhipu/batch.go create mode 100644 zhipu/batch_support.go create mode 100644 zhipu/batch_support_test.go create mode 100644 zhipu/batch_test.go create mode 100644 zhipu/chat_completion.go create mode 100644 zhipu/chat_completion_test.go create mode 100644 zhipu/client.go create mode 100644 zhipu/client_test.go create mode 100644 zhipu/cog.toml create mode 100644 zhipu/embedding.go create mode 100644 zhipu/embedding_test.go create mode 100644 zhipu/error.go create mode 100644 zhipu/error_test.go create mode 100644 zhipu/file.go create mode 100644 zhipu/file_test.go create mode 100644 zhipu/fine_tune.go create mode 100644 zhipu/fine_tune_test.go create mode 100644 zhipu/image_generation.go create mode 100644 zhipu/image_generation_test.go create mode 100644 zhipu/knowledge.go create mode 100644 zhipu/knowledge_test.go create mode 100644 zhipu/string_or.go create mode 100644 zhipu/string_or_test.go create mode 100644 zhipu/testdata/test-file.jsonl create mode 100644 zhipu/testdata/test-file.txt create mode 100644 zhipu/util.go create mode 100644 zhipu/util_test.go create mode 100644 zhipu/video_generation.go create mode 100644 zhipu/video_generation_test.go create mode 100644 zhipu/wechat-donation.png diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f166f95 --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2024 apigo + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/aigc.go b/aigc.go new file mode 100644 index 0000000..f9540db --- /dev/null +++ b/aigc.go @@ -0,0 +1,83 @@ +package zhipu + +import ( + "apigo.cc/ai/zhipu/zhipu" + "context" + "errors" + "time" +) + +func (ag *Agent) FastMakeImage(prompt, size, refImage string) ([]string, error) { + return ag.MakeImage(ModelCogView3Plus, prompt, size, refImage) +} + +func (ag *Agent) BestMakeImage(prompt, size, refImage string) ([]string, error) { + return ag.MakeImage(ModelCogView3, prompt, size, refImage) +} + +func (ag *Agent) MakeImage(model, prompt, size, refImage string) ([]string, error) { + c, err := zhipu.NewClient(zhipu.WithAPIKey(ag.config.ApiKey), zhipu.WithBaseURL(ag.config.Endpoint)) + if err != nil { + return nil, err + } + + cc := c.ImageGeneration(model).SetPrompt(prompt) + if size != "" { + cc.SetSize(size) + } + + if r, err := cc.Do(context.Background()); err == nil { + results := make([]string, 0) + for _, item := range r.Data { + results = append(results, item.URL) + } + return results, nil + } else { + return nil, err + } +} + +func (ag *Agent) FastMakeVideo(prompt, size, refImage string) ([]string, []string, error) { + return ag.MakeVideo(ModelCogVideoX, prompt, size, refImage) +} + +func (ag *Agent) BestMakeVideo(prompt, size, refImage string) ([]string, []string, error) { + return ag.MakeVideo(ModelCogVideoX, prompt, size, refImage) +} + +func (ag *Agent) MakeVideo(model, prompt, size, refImage string) ([]string, []string, error) { + c, err := zhipu.NewClient(zhipu.WithAPIKey(ag.config.ApiKey), zhipu.WithBaseURL(ag.config.Endpoint)) + if err != nil { + return nil, nil, err + } + + cc := c.VideoGeneration(model).SetPrompt(prompt) + if refImage != "" { + cc.SetImageURL(refImage) + } + + if resp, err := cc.Do(context.Background()); err == nil { + for i := 0; i < 1200; i++ { + r, err := c.AsyncResult(resp.ID).Do(context.Background()) + if err != nil { + return nil, nil, err + } + if r.TaskStatus == zhipu.VideoGenerationTaskStatusSuccess { + covers := make([]string, 0) + results := make([]string, 0) + for _, item := range r.VideoResult { + results = append(results, item.URL) + covers = append(covers, item.CoverImageURL) + } + return results, covers, nil + } + if r.TaskStatus == zhipu.VideoGenerationTaskStatusFail { + return nil, nil, errors.New("fail on task " + resp.ID) + } + time.Sleep(3 * time.Second) + } + return nil, nil, errors.New("timeout on task " + resp.ID) + } else { + return nil, nil, err + } +} diff --git a/chat.go b/chat.go new file mode 100644 index 0000000..2151f07 --- /dev/null +++ b/chat.go @@ -0,0 +1,140 @@ +package zhipu + +import ( + "apigo.cc/ai/agent" + "apigo.cc/ai/zhipu/zhipu" + "context" + "strings" +) + +func (ag *Agent) FastAsk(messages []agent.ChatMessage, callback func(answer string)) (string, agent.TokenUsage, error) { + return ag.Ask(messages, &agent.ChatModelConfig{ + Model: ModelGLM4Flash, + }, callback) +} + +func (ag *Agent) LongAsk(messages []agent.ChatMessage, callback func(answer string)) (string, agent.TokenUsage, error) { + return ag.Ask(messages, &agent.ChatModelConfig{ + Model: ModelGLM4Long, + }, callback) +} + +func (ag *Agent) BatterAsk(messages []agent.ChatMessage, callback func(answer string)) (string, agent.TokenUsage, error) { + return ag.Ask(messages, &agent.ChatModelConfig{ + Model: ModelGLM4Plus, + }, callback) +} + +func (ag *Agent) BestAsk(messages []agent.ChatMessage, callback func(answer string)) (string, agent.TokenUsage, error) { + return ag.Ask(messages, &agent.ChatModelConfig{ + Model: ModelGLM40520, + }, callback) +} + +func (ag *Agent) MultiAsk(messages []agent.ChatMessage, callback func(answer string)) (string, agent.TokenUsage, error) { + return ag.Ask(messages, &agent.ChatModelConfig{ + Model: ModelGLM4VPlus, + }, callback) +} + +func (ag *Agent) BestMultiAsk(messages []agent.ChatMessage, callback func(answer string)) (string, agent.TokenUsage, error) { + return ag.Ask(messages, &agent.ChatModelConfig{ + Model: ModelGLM4V, + }, callback) +} + +func (ag *Agent) CodeInterpreterAsk(messages []agent.ChatMessage, callback func(answer string)) (string, agent.TokenUsage, error) { + return ag.Ask(messages, &agent.ChatModelConfig{ + Model: ModelGLM4AllTools, + Tools: map[string]any{agent.ToolCodeInterpreter: nil}, + }, callback) +} + +func (ag *Agent) WebSearchAsk(messages []agent.ChatMessage, callback func(answer string)) (string, agent.TokenUsage, error) { + return ag.Ask(messages, &agent.ChatModelConfig{ + Model: ModelGLM4AllTools, + Tools: map[string]any{agent.ToolWebSearch: nil}, + }, callback) +} + +func (ag *Agent) Ask(messages []agent.ChatMessage, config *agent.ChatModelConfig, callback func(answer string)) (string, agent.TokenUsage, error) { + if config == nil { + config = &agent.ChatModelConfig{} + } + config.SetDefault(&ag.config.DefaultChatModelConfig) + c, err := zhipu.NewClient(zhipu.WithAPIKey(ag.config.ApiKey), zhipu.WithBaseURL(ag.config.Endpoint)) + if err != nil { + return "", agent.TokenUsage{}, err + } + + cc := c.ChatCompletion(config.GetModel()) + for _, msg := range messages { + var contents []zhipu.ChatCompletionMultiContent + if msg.Contents != nil { + contents = make([]zhipu.ChatCompletionMultiContent, len(msg.Contents)) + for j, inPart := range msg.Contents { + part := zhipu.ChatCompletionMultiContent{} + part.Type = NameMap[inPart.Type] + switch inPart.Type { + case agent.TypeText: + part.Text = inPart.Content + case agent.TypeImage: + part.ImageURL = &zhipu.URLItem{URL: inPart.Content} + case agent.TypeVideo: + part.VideoURL = &zhipu.URLItem{URL: inPart.Content} + } + contents[j] = part + } + } + cc.AddMessage(zhipu.ChatCompletionMultiMessage{ + Role: NameMap[msg.Role], + Content: contents, + }) + } + + for name := range config.GetTools() { + switch name { + case agent.ToolCodeInterpreter: + cc.AddTool(zhipu.ChatCompletionToolCodeInterpreter{}) + case agent.ToolWebSearch: + cc.AddTool(zhipu.ChatCompletionToolWebBrowser{}) + } + } + + if config.GetMaxTokens() != 0 { + cc.SetMaxTokens(config.GetMaxTokens()) + } + if config.GetTemperature() != 0 { + cc.SetTemperature(config.GetTemperature()) + } + if config.GetTopP() != 0 { + cc.SetTopP(config.GetTopP()) + } + if callback != nil { + cc.SetStreamHandler(func(r2 zhipu.ChatCompletionResponse) error { + if r2.Choices != nil { + for _, ch := range r2.Choices { + text := ch.Delta.Content + callback(text) + } + } + return nil + }) + } + + if r, err := cc.Do(context.Background()); err == nil { + results := make([]string, 0) + if r.Choices != nil { + for _, ch := range r.Choices { + results = append(results, ch.Message.Content) + } + } + return strings.Join(results, ""), agent.TokenUsage{ + AskTokens: r.Usage.PromptTokens, + AnswerTokens: r.Usage.CompletionTokens, + TotalTokens: r.Usage.TotalTokens, + }, nil + } else { + return "", agent.TokenUsage{}, err + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..a8e57b0 --- /dev/null +++ b/config.go @@ -0,0 +1,60 @@ +package zhipu + +import ( + "apigo.cc/ai/agent" + "apigo.cc/ai/zhipu/zhipu" +) + +type Agent struct { + config agent.APIConfig +} + +var NameMap = map[string]string{ + agent.TypeText: zhipu.MultiContentTypeText, + agent.TypeImage: zhipu.MultiContentTypeImageURL, + agent.TypeVideo: zhipu.MultiContentTypeVideoURL, + agent.RoleSystem: zhipu.RoleSystem, + agent.RoleUser: zhipu.RoleUser, + agent.RoleAssistant: zhipu.RoleAssistant, + agent.RoleTool: zhipu.RoleTool, +} + +const ( + ModelGLM4Plus = "GLM-4-Plus" + ModelGLM40520 = "GLM-4-0520" + ModelGLM4Long = "GLM-4-Long" + ModelGLM4AirX = "GLM-4-AirX" + ModelGLM4Air = "GLM-4-Air" + ModelGLM4Flash = "GLM-4-Flash" + ModelGLM4AllTools = "GLM-4-AllTools" + ModelGLM4 = "GLM-4" + ModelGLM4VPlus = "GLM-4V-Plus" + ModelGLM4V = "GLM-4V" + ModelCogVideoX = "CogVideoX" + ModelCogView3Plus = "CogView-3-Plus" + ModelCogView3 = "CogView-3" + ModelEmbedding3 = "Embedding-3" + ModelEmbedding2 = "Embedding-2" + ModelCharGLM3 = "CharGLM-3" + ModelEmohaa = "Emohaa" + ModelCodeGeeX4 = "CodeGeeX-4" +) + +func (ag *Agent) Support() agent.Support { + return agent.Support{ + Ask: true, + AskWithImage: true, + AskWithVideo: true, + AskWithCodeInterpreter: true, + AskWithWebSearch: true, + MakeImage: true, + MakeVideo: true, + Models: []string{ModelGLM4Plus, ModelGLM40520, ModelGLM4Long, ModelGLM4AirX, ModelGLM4Air, ModelGLM4Flash, ModelGLM4AllTools, ModelGLM4, ModelGLM4VPlus, ModelGLM4V, ModelCogVideoX, ModelCogView3Plus, ModelCogView3, ModelEmbedding3, ModelEmbedding2, ModelCharGLM3, ModelEmohaa, ModelCodeGeeX4}, + } +} + +func init() { + agent.RegisterAgentMaker("zhipu", func(config agent.APIConfig) agent.Agent { + return &Agent{config: config} + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fd5f3e2 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module apigo.cc/ai/zhipu + +go 1.22 + +require ( + apigo.cc/ai/agent v0.0.1 + github.com/go-resty/resty/v2 v2.14.0 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/stretchr/testify v1.9.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/net v0.29.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/zhipu/CHANGELOG.md b/zhipu/CHANGELOG.md new file mode 100644 index 0000000..8e1033f --- /dev/null +++ b/zhipu/CHANGELOG.md @@ -0,0 +1,108 @@ +# Changelog +All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines. + +- - - +## v0.1.2 - 2024-08-15 +#### Bug Fixes +- add FinishReasonStopSequence - (01b4201) - GUO YANKE +#### Documentation +- update README.md [skip ci] - (e48a88b) - GUO YANKE +#### Features +- add videos/generations - (7261999) - GUO YANKE +#### Miscellaneous Chores +- relaxing go version to 1.18 - (6acc17c) - GUO YANKE + +- - - + +## v0.1.1 - 2024-07-17 +#### Documentation +- update README.md [skip ci] - (695432a) - GUO YANKE +#### Features +- add support for GLM-4-AllTools - (9627a36) - GUO YANKE + +- - - + +## v0.1.0 - 2024-06-28 +#### Bug Fixes +- rename client function for batch list - (40ac05f) - GUO YANKE +#### Documentation +- update README.md [skip ci] - (6ce5754) - GUO YANKE +#### Features +- add knowledge capacity service - (4ce62b3) - GUO YANKE +#### Refactoring +- update batch service - (b92d438) - GUO YANKE +- update chat completion service - (19dd77f) - GUO YANKE +- update embedding service - (c1bbc2d) - GUO YANKE +- update file services - (7ef4d87) - GUO YANKE +- update fine tune services, using APIError - (15aed88) - GUO YANKE +- update fine tune services - (664523b) - GUO YANKE +- update image generation service - (a18e028) - GUO YANKE +- update knowledge services - (c7bfb73) - GUO YANKE + +- - - + +## v0.0.6 - 2024-06-28 +#### Features +- add batch support for result reader - (c062095) - GUO YANKE +- add fine tune services - (f172f51) - GUO YANKE +- add knowledge service - (09792b5) - GUO YANKE + +- - - + +## v0.0.5 - 2024-06-28 +#### Bug Fixes +- api error parsing - (60a17f4) - GUO YANKE +#### Features +- add batch service - (389aec3) - GUO YANKE +- add batch support for chat completions, image generations and embeddings - (c017ffd) - GUO YANKE +- add file edit/get/delete service - (8a4d309) - GUO YANKE +- add file create serivce - (6d2140b) - GUO YANKE + +- - - + +## v0.0.4 - 2024-06-26 +#### Bug Fixes +- remove Client.R(), hide resty for future removal - (dc2a4ca) - GUO YANKE +#### Features +- add meta support for charglm - (fdd20e7) - GUO YANKE +- add client option to custom http client - (c62d6a9) - GUO YANKE + +- - - + +## v0.0.3 - 2024-06-26 +#### Features +- add image generation service - (9f3f54f) - GUO YANKE +- add support for vision models - (2dcd82a) - GUO YANKE +- add embedding service - (f57806a) - GUO YANKE + +- - - + +## v0.0.2 - 2024-06-26 +#### Bug Fixes +- **(deps)** update golang-jwt/jwt to v5 - (2f76a57) - GUO YANKE +#### Features +- add constants for roles - (3d08a72) - GUO YANKE + +- - - + +## v0.0.1 - 2024-06-26 +#### Bug Fixes +- add json tag "omitempty" to various types - (bf81097) - GUO YANKE +#### Continuous Integration +- add github action workflows for testing - (5a64987) - GUO YANKE +#### Documentation +- update README.md [skip ci] - (d504f57) - GUO YANKE +#### Features +- add chat completion in stream mode - (130fe1d) - GUO YANKE +- add chat completion in non-stream mode - (2326e37) - GUO YANKE +- support debug option while creating client - (0f104d8) - GUO YANKE +- add APIError and APIErrorResponse - (1886d85) - GUO YANKE +- add client struct - (710d8e8) - GUO YANKE +#### Refactoring +- change signature of Client#createJWT since there is no reason to fail - (f0d7887) - GUO YANKE +#### Tests +- add client_test.go - (a3fc217) - GUO YANKE + +- - - + +Changelog generated by [cocogitto](https://github.com/cocogitto/cocogitto). \ No newline at end of file diff --git a/zhipu/LICENSE b/zhipu/LICENSE new file mode 100644 index 0000000..67dc60b --- /dev/null +++ b/zhipu/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Yanke G. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/zhipu/README.md b/zhipu/README.md new file mode 100644 index 0000000..555e351 --- /dev/null +++ b/zhipu/README.md @@ -0,0 +1,280 @@ +# zhipu + +[![Go Reference](https://pkg.go.dev/badge/github.com/yankeguo/zhipu.svg)](https://pkg.go.dev/github.com/yankeguo/zhipu) +[![go](https://github.com/yankeguo/zhipu/actions/workflows/go.yml/badge.svg)](https://github.com/yankeguo/zhipu/actions/workflows/go.yml) + +[中文文档](README.zh.md) + +A 3rd-Party Golang Client Library for Zhipu AI Platform + +## Usage + +### Install the package + +```bash +go get -u github.com/yankeguo/zhipu +``` + +### Create a client + +```go +// this will use environment variables ZHIPUAI_API_KEY +client, err := zhipu.NewClient() +// or you can specify the API key +client, err = zhipu.NewClient(zhipu.WithAPIKey("your api key")) +``` + +### Use the client + +**ChatCompletion** + +```go +service := client.ChatCompletion("glm-4-flash"). + AddMessage(zhipu.ChatCompletionMessage{ + Role: "user", + Content: "你好", + }) + +res, err := service.Do(context.Background()) + +if err != nil { + zhipu.GetAPIErrorCode(err) // get the API error code +} else { + println(res.Choices[0].Message.Content) +} +``` + +**ChatCompletion (Stream)** + +```go +service := client.ChatCompletion("glm-4-flash"). + AddMessage(zhipu.ChatCompletionMessage{ + Role: "user", + Content: "你好", + }).SetStreamHandler(func(chunk zhipu.ChatCompletionResponse) error { + println(chunk.Choices[0].Delta.Content) + return nil + }) + +res, err := service.Do(context.Background()) + +if err != nil { + zhipu.GetAPIErrorCode(err) // get the API error code +} else { + // this package will combine the stream chunks and build a final result mimicking the non-streaming API + println(res.Choices[0].Message.Content) +} +``` + +**ChatCompletion (Stream with GLM-4-AllTools)** + +```go +// CodeInterpreter +s := client.ChatCompletion("GLM-4-AllTools") +s.AddMessage(zhipu.ChatCompletionMultiMessage{ + Role: "user", + Content: []zhipu.ChatCompletionMultiContent{ + { + Type: "text", + Text: "计算[5,10,20,700,99,310,978,100]的平均值和方差。", + }, + }, +}) +s.AddTool(zhipu.ChatCompletionToolCodeInterpreter{ + Sandbox: zhipu.Ptr(CodeInterpreterSandboxAuto), +}) +s.SetStreamHandler(func(chunk zhipu.ChatCompletionResponse) error { + for _, c := range chunk.Choices { + for _, tc := range c.Delta.ToolCalls { + if tc.Type == ToolTypeCodeInterpreter && tc.CodeInterpreter != nil { + if tc.CodeInterpreter.Input != "" { + // DO SOMETHING + } + if len(tc.CodeInterpreter.Outputs) > 0 { + // DO SOMETHING + } + } + } + } + return nil +}) + +// WebBrowser +// CAUTION: NOT 'WebSearch' +s := client.ChatCompletion("GLM-4-AllTools") +s.AddMessage(zhipu.ChatCompletionMultiMessage{ + Role: "user", + Content: []zhipu.ChatCompletionMultiContent{ + { + Type: "text", + Text: "搜索下本周深圳天气如何", + }, + }, +}) +s.AddTool(zhipu.ChatCompletionToolWebBrowser{}) +s.SetStreamHandler(func(chunk zhipu.ChatCompletionResponse) error { + for _, c := range chunk.Choices { + for _, tc := range c.Delta.ToolCalls { + if tc.Type == ToolTypeWebBrowser && tc.WebBrowser != nil { + if tc.WebBrowser.Input != "" { + // DO SOMETHING + } + if len(tc.WebBrowser.Outputs) > 0 { + // DO SOMETHING + } + } + } + } + return nil +}) +s.Do(context.Background()) + +// DrawingTool +s := client.ChatCompletion("GLM-4-AllTools") +s.AddMessage(zhipu.ChatCompletionMultiMessage{ + Role: "user", + Content: []zhipu.ChatCompletionMultiContent{ + { + Type: "text", + Text: "画一个正弦函数图像", + }, + }, +}) +s.AddTool(zhipu.ChatCompletionToolDrawingTool{}) +s.SetStreamHandler(func(chunk zhipu.ChatCompletionResponse) error { + for _, c := range chunk.Choices { + for _, tc := range c.Delta.ToolCalls { + if tc.Type == ToolTypeDrawingTool && tc.DrawingTool != nil { + if tc.DrawingTool.Input != "" { + // DO SOMETHING + } + if len(tc.DrawingTool.Outputs) > 0 { + // DO SOMETHING + } + } + } + } + return nil +}) +s.Do(context.Background()) +``` + +**Embedding** + +```go +service := client.Embedding("embedding-v2").SetInput("你好呀") +service.Do(context.Background()) +``` + +**Image Generation** + +```go +service := client.ImageGeneration("cogview-3").SetPrompt("一只可爱的小猫咪") +service.Do(context.Background()) +``` + +**Video Generation** + +```go +service := client.VideoGeneration("cogvideox").SetPrompt("一只可爱的小猫咪") +resp, err := service.Do(context.Background()) + +for { + result, err := client.AsyncResult(resp.ID).Do(context.Background()) + + if result.TaskStatus == zhipu.VideoGenerationTaskStatusSuccess { + _ = result.VideoResult[0].URL + _ = result.VideoResult[0].CoverImageURL + break + } + + if result.TaskStatus != zhipu.VideoGenerationTaskStatusProcessing { + break + } + + time.Sleep(5 * time.Second) +} +``` + +**Upload File (Retrieval)** + +```go +service := client.FileCreate(zhipu.FilePurposeRetrieval) +service.SetLocalFile(filepath.Join("testdata", "test-file.txt")) +service.SetKnowledgeID("your-knowledge-id") + +service.Do(context.Background()) +``` + +**Upload File (Fine-Tune)** + +```go +service := client.FileCreate(zhipu.FilePurposeFineTune) +service.SetLocalFile(filepath.Join("testdata", "test-file.jsonl")) +service.Do(context.Background()) +``` + +**Batch Create** + +```go +service := client.BatchCreate(). + SetInputFileID("fileid"). + SetCompletionWindow(zhipu.BatchCompletionWindow24h). + SetEndpoint(BatchEndpointV4ChatCompletions) +service.Do(context.Background()) +``` + +**Knowledge Base** + +```go +client.KnowledgeCreate("") +client.KnowledgeEdit("") +``` + +**Fine Tune** + +```go +client.FineTuneCreate("") +``` + +### Batch Support + +**Batch File Writer** + +```go +f, err := os.OpenFile("batch.jsonl", os.O_CREATE|os.O_WRONLY, 0644) + +bw := zhipu.NewBatchFileWriter(f) + +bw.Add("action_1", client.ChatCompletion("glm-4-flash"). + AddMessage(zhipu.ChatCompletionMessage{ + Role: "user", + Content: "你好", + })) +bw.Add("action_2", client.Embedding("embedding-v2").SetInput("你好呀")) +bw.Add("action_3", client.ImageGeneration("cogview-3").SetPrompt("一只可爱的小猫咪")) +``` + +**Batch Result Reader** + +```go +br := zhipu.NewBatchResultReader[zhipu.ChatCompletionResponse](r) + +for { + var res zhipu.BatchResult[zhipu.ChatCompletionResponse] + err := br.Read(&res) + if err != nil { + break + } +} +``` + +## Donation + +Executing unit tests will actually call the ChatGLM API and consume my quota. Please donate and thank you for your support! + + + +## Credits + +GUO YANKE, MIT License diff --git a/zhipu/README.zh.md b/zhipu/README.zh.md new file mode 100644 index 0000000..2563f05 --- /dev/null +++ b/zhipu/README.zh.md @@ -0,0 +1,278 @@ +# zhipu + +[![Go Reference](https://pkg.go.dev/badge/github.com/yankeguo/zhipu.svg)](https://pkg.go.dev/github.com/yankeguo/zhipu) +[![go](https://github.com/yankeguo/zhipu/actions/workflows/go.yml/badge.svg)](https://github.com/yankeguo/zhipu/actions/workflows/go.yml) + +Zhipu AI 平台第三方 Golang 客户端库 + +## 用法 + +### 安装库 + +```bash +go get -u github.com/yankeguo/zhipu +``` + +### 创建客户端 + +```go +// 默认使用环境变量 ZHIPUAI_API_KEY +client, err := zhipu.NewClient() +// 或者手动指定密钥 +client, err = zhipu.NewClient(zhipu.WithAPIKey("your api key")) +``` + +### 使用客户端 + +**ChatCompletion(大语言模型)** + +```go +service := client.ChatCompletion("glm-4-flash"). + AddMessage(zhipu.ChatCompletionMessage{ + Role: "user", + Content: "你好", + }) + +res, err := service.Do(context.Background()) + +if err != nil { + zhipu.GetAPIErrorCode(err) // get the API error code +} else { + println(res.Choices[0].Message.Content) +} +``` + +**ChatCompletion(流式调用大语言模型)** + +```go +service := client.ChatCompletion("glm-4-flash"). + AddMessage(zhipu.ChatCompletionMessage{ + Role: "user", + Content: "你好", + }).SetStreamHandler(func(chunk zhipu.ChatCompletionResponse) error { + println(chunk.Choices[0].Delta.Content) + return nil + }) + +res, err := service.Do(context.Background()) + +if err != nil { + zhipu.GetAPIErrorCode(err) // get the API error code +} else { + // this package will combine the stream chunks and build a final result mimicking the non-streaming API + println(res.Choices[0].Message.Content) +} +``` + +**ChatCompletion(流式调用大语言工具模型GLM-4-AllTools)** + +```go +// CodeInterpreter +s := client.ChatCompletion("GLM-4-AllTools") +s.AddMessage(zhipu.ChatCompletionMultiMessage{ + Role: "user", + Content: []zhipu.ChatCompletionMultiContent{ + { + Type: "text", + Text: "计算[5,10,20,700,99,310,978,100]的平均值和方差。", + }, + }, +}) +s.AddTool(zhipu.ChatCompletionToolCodeInterpreter{ + Sandbox: zhipu.Ptr(CodeInterpreterSandboxAuto), +}) +s.SetStreamHandler(func(chunk zhipu.ChatCompletionResponse) error { + for _, c := range chunk.Choices { + for _, tc := range c.Delta.ToolCalls { + if tc.Type == ToolTypeCodeInterpreter && tc.CodeInterpreter != nil { + if tc.CodeInterpreter.Input != "" { + // DO SOMETHING + } + if len(tc.CodeInterpreter.Outputs) > 0 { + // DO SOMETHING + } + } + } + } + return nil +}) + +// WebBrowser +// CAUTION: NOT 'WebSearch' +s := client.ChatCompletion("GLM-4-AllTools") +s.AddMessage(zhipu.ChatCompletionMultiMessage{ + Role: "user", + Content: []zhipu.ChatCompletionMultiContent{ + { + Type: "text", + Text: "搜索下本周深圳天气如何", + }, + }, +}) +s.AddTool(zhipu.ChatCompletionToolWebBrowser{}) +s.SetStreamHandler(func(chunk zhipu.ChatCompletionResponse) error { + for _, c := range chunk.Choices { + for _, tc := range c.Delta.ToolCalls { + if tc.Type == ToolTypeWebBrowser && tc.WebBrowser != nil { + if tc.WebBrowser.Input != "" { + // DO SOMETHING + } + if len(tc.WebBrowser.Outputs) > 0 { + // DO SOMETHING + } + } + } + } + return nil +}) +s.Do(context.Background()) + +// DrawingTool +s := client.ChatCompletion("GLM-4-AllTools") +s.AddMessage(zhipu.ChatCompletionMultiMessage{ + Role: "user", + Content: []zhipu.ChatCompletionMultiContent{ + { + Type: "text", + Text: "画一个正弦函数图像", + }, + }, +}) +s.AddTool(zhipu.ChatCompletionToolDrawingTool{}) +s.SetStreamHandler(func(chunk zhipu.ChatCompletionResponse) error { + for _, c := range chunk.Choices { + for _, tc := range c.Delta.ToolCalls { + if tc.Type == ToolTypeDrawingTool && tc.DrawingTool != nil { + if tc.DrawingTool.Input != "" { + // DO SOMETHING + } + if len(tc.DrawingTool.Outputs) > 0 { + // DO SOMETHING + } + } + } + } + return nil +}) +s.Do(context.Background()) +``` + +**Embedding** + +```go +service := client.Embedding("embedding-v2").SetInput("你好呀") +service.Do(context.Background()) +``` + +**ImageGeneration(图像生成)** + +```go +service := client.ImageGeneration("cogview-3").SetPrompt("一只可爱的小猫咪") +service.Do(context.Background()) +``` + +**VideoGeneration(视频生成)** + +```go +service := client.VideoGeneration("cogvideox").SetPrompt("一只可爱的小猫咪") +resp, err := service.Do(context.Background()) + +for { + result, err := client.AsyncResult(resp.ID).Do(context.Background()) + + if result.TaskStatus == zhipu.VideoGenerationTaskStatusSuccess { + _ = result.VideoResult[0].URL + _ = result.VideoResult[0].CoverImageURL + break + } + + if result.TaskStatus != zhipu.VideoGenerationTaskStatusProcessing { + break + } + + time.Sleep(5 * time.Second) +} +``` + +**UploadFile(上传文件用于取回)** + +```go +service := client.FileCreate(zhipu.FilePurposeRetrieval) +service.SetLocalFile(filepath.Join("testdata", "test-file.txt")) +service.SetKnowledgeID("your-knowledge-id") + +service.Do(context.Background()) +``` + +**UploadFile(上传文件用于微调)** + +```go +service := client.FileCreate(zhipu.FilePurposeFineTune) +service.SetLocalFile(filepath.Join("testdata", "test-file.jsonl")) +service.Do(context.Background()) +``` + +**BatchCreate(创建批量任务)** + +```go +service := client.BatchCreate(). + SetInputFileID("fileid"). + SetCompletionWindow(zhipu.BatchCompletionWindow24h). + SetEndpoint(BatchEndpointV4ChatCompletions) +service.Do(context.Background()) +``` + +**KnowledgeBase(知识库)** + +```go +client.KnowledgeCreate("") +client.KnowledgeEdit("") +``` + +**FineTune(微调)** + +```go +client.FineTuneCreate("") +``` + +### 批量任务辅助工具 + +**批量任务文件创建** + +```go +f, err := os.OpenFile("batch.jsonl", os.O_CREATE|os.O_WRONLY, 0644) + +bw := zhipu.NewBatchFileWriter(f) + +bw.Add("action_1", client.ChatCompletion("glm-4-flash"). + AddMessage(zhipu.ChatCompletionMessage{ + Role: "user", + Content: "你好", + })) +bw.Add("action_2", client.Embedding("embedding-v2").SetInput("你好呀")) +bw.Add("action_3", client.ImageGeneration("cogview-3").SetPrompt("一只可爱的小猫咪")) +``` + +**批量任务结果解析** + +```go +br := zhipu.NewBatchResultReader[zhipu.ChatCompletionResponse](r) + +for { + var res zhipu.BatchResult[zhipu.ChatCompletionResponse] + err := br.Read(&res) + if err != nil { + break + } +} +``` + +## 赞助 + +执行单元测试会真实调用GLM接口,消耗我充值的额度,开发不易,请微信扫码捐赠,感谢您的支持! + + + +## 许可证 + +GUO YANKE, MIT License diff --git a/zhipu/async_result.go b/zhipu/async_result.go new file mode 100644 index 0000000..5e51db1 --- /dev/null +++ b/zhipu/async_result.go @@ -0,0 +1,63 @@ +package zhipu + +import ( + "context" + + "github.com/go-resty/resty/v2" +) + +// AsyncResultService creates a new async result get service +type AsyncResultService struct { + client *Client + + id string +} + +// AsyncResultVideo is the video result of the AsyncResultService +type AsyncResultVideo struct { + URL string `json:"url"` + CoverImageURL string `json:"cover_image_url"` +} + +// AsyncResultResponse is the response of the AsyncResultService +type AsyncResultResponse struct { + Model string `json:"model"` + TaskStatus string `json:"task_status"` + RequestID string `json:"request_id"` + ID string `json:"id"` + VideoResult []AsyncResultVideo `json:"video_result"` +} + +// NewAsyncResultService creates a new async result get service +func NewAsyncResultService(client *Client) *AsyncResultService { + return &AsyncResultService{ + client: client, + } +} + +// SetID sets the id parameter +func (s *AsyncResultService) SetID(id string) *AsyncResultService { + s.id = id + return s +} + +func (s *AsyncResultService) Do(ctx context.Context) (res AsyncResultResponse, err error) { + var ( + resp *resty.Response + apiError APIErrorResponse + ) + + if resp, err = s.client.request(ctx). + SetResult(&res). + SetError(&apiError). + Get("async-result/" + s.id); err != nil { + return + } + + if resp.IsError() { + err = apiError + return + } + + return +} diff --git a/zhipu/async_result_test.go b/zhipu/async_result_test.go new file mode 100644 index 0000000..f4a78b5 --- /dev/null +++ b/zhipu/async_result_test.go @@ -0,0 +1 @@ +package zhipu diff --git a/zhipu/batch.go b/zhipu/batch.go new file mode 100644 index 0000000..d661f56 --- /dev/null +++ b/zhipu/batch.go @@ -0,0 +1,258 @@ +package zhipu + +import ( + "context" + "encoding/json" + "strconv" + + "github.com/go-resty/resty/v2" +) + +const ( + BatchEndpointV4ChatCompletions = "/v4/chat/completions" + BatchEndpointV4ImagesGenerations = "/v4/images/generations" + BatchEndpointV4Embeddings = "/v4/embeddings" + BatchEndpointV4VideosGenerations = "/v4/videos/generations" + + BatchCompletionWindow24h = "24h" +) + +// BatchRequestCounts represents the counts of the batch requests. +type BatchRequestCounts struct { + Total int64 `json:"total"` + Completed int64 `json:"completed"` + Failed int64 `json:"failed"` +} + +// BatchItem represents a batch item. +type BatchItem struct { + ID string `json:"id"` + Object any `json:"object"` + Endpoint string `json:"endpoint"` + InputFileID string `json:"input_file_id"` + CompletionWindow string `json:"completion_window"` + Status string `json:"status"` + OutputFileID string `json:"output_file_id"` + ErrorFileID string `json:"error_file_id"` + CreatedAt int64 `json:"created_at"` + InProgressAt int64 `json:"in_progress_at"` + ExpiresAt int64 `json:"expires_at"` + FinalizingAt int64 `json:"finalizing_at"` + CompletedAt int64 `json:"completed_at"` + FailedAt int64 `json:"failed_at"` + ExpiredAt int64 `json:"expired_at"` + CancellingAt int64 `json:"cancelling_at"` + CancelledAt int64 `json:"cancelled_at"` + RequestCounts BatchRequestCounts `json:"request_counts"` + Metadata json.RawMessage `json:"metadata"` +} + +// BatchCreateService is a service to create a batch. +type BatchCreateService struct { + client *Client + + inputFileID string + endpoint string + completionWindow string + metadata any +} + +// NewBatchCreateService creates a new BatchCreateService. +func NewBatchCreateService(client *Client) *BatchCreateService { + return &BatchCreateService{client: client} +} + +// SetInputFileID sets the input file id for the batch. +func (s *BatchCreateService) SetInputFileID(inputFileID string) *BatchCreateService { + s.inputFileID = inputFileID + return s +} + +// SetEndpoint sets the endpoint for the batch. +func (s *BatchCreateService) SetEndpoint(endpoint string) *BatchCreateService { + s.endpoint = endpoint + return s +} + +// SetCompletionWindow sets the completion window for the batch. +func (s *BatchCreateService) SetCompletionWindow(window string) *BatchCreateService { + s.completionWindow = window + return s +} + +// SetMetadata sets the metadata for the batch. +func (s *BatchCreateService) SetMetadata(metadata any) *BatchCreateService { + s.metadata = metadata + return s +} + +// Do executes the batch create service. +func (s *BatchCreateService) Do(ctx context.Context) (res BatchItem, err error) { + var ( + resp *resty.Response + apiError APIErrorResponse + ) + + if resp, err = s.client.request(ctx). + SetBody(M{ + "input_file_id": s.inputFileID, + "endpoint": s.endpoint, + "completion_window": s.completionWindow, + "metadata": s.metadata, + }). + SetResult(&res). + SetError(&apiError). + Post("batches"); err != nil { + return + } + + if resp.IsError() { + err = apiError + } + + return +} + +// BatchGetService is a service to get a batch. +type BatchGetService struct { + client *Client + batchID string +} + +// BatchGetResponse represents the response of the batch get service. +type BatchGetResponse = BatchItem + +// NewBatchGetService creates a new BatchGetService. +func NewBatchGetService(client *Client) *BatchGetService { + return &BatchGetService{client: client} +} + +// SetBatchID sets the batch id for the batch get service. +func (s *BatchGetService) SetBatchID(batchID string) *BatchGetService { + s.batchID = batchID + return s +} + +// Do executes the batch get service. +func (s *BatchGetService) Do(ctx context.Context) (res BatchGetResponse, err error) { + var ( + resp *resty.Response + apiError APIErrorResponse + ) + + if resp, err = s.client.request(ctx). + SetPathParam("batch_id", s.batchID). + SetResult(&res). + SetError(&apiError). + Get("batches/{batch_id}"); err != nil { + return + } + + if resp.IsError() { + err = apiError + } + + return +} + +// BatchCancelService is a service to cancel a batch. +type BatchCancelService struct { + client *Client + batchID string +} + +// NewBatchCancelService creates a new BatchCancelService. +func NewBatchCancelService(client *Client) *BatchCancelService { + return &BatchCancelService{client: client} +} + +// SetBatchID sets the batch id for the batch cancel service. +func (s *BatchCancelService) SetBatchID(batchID string) *BatchCancelService { + s.batchID = batchID + return s +} + +// Do executes the batch cancel service. +func (s *BatchCancelService) Do(ctx context.Context) (err error) { + var ( + resp *resty.Response + apiError APIErrorResponse + ) + + if resp, err = s.client.request(ctx). + SetPathParam("batch_id", s.batchID). + SetBody(M{}). + SetError(&apiError). + Post("batches/{batch_id}/cancel"); err != nil { + return + } + + if resp.IsError() { + err = apiError + } + + return +} + +// BatchListService is a service to list batches. +type BatchListService struct { + client *Client + + after *string + limit *int +} + +// BatchListResponse represents the response of the batch list service. +type BatchListResponse struct { + Object string `json:"object"` + Data []BatchItem `json:"data"` + FirstID string `json:"first_id"` + LastID string `json:"last_id"` + HasMore bool `json:"has_more"` +} + +// NewBatchListService creates a new BatchListService. +func NewBatchListService(client *Client) *BatchListService { + return &BatchListService{client: client} +} + +// SetAfter sets the after cursor for the batch list service. +func (s *BatchListService) SetAfter(after string) *BatchListService { + s.after = &after + return s +} + +// SetLimit sets the limit for the batch list service. +func (s *BatchListService) SetLimit(limit int) *BatchListService { + s.limit = &limit + return s +} + +// Do executes the batch list service. +func (s *BatchListService) Do(ctx context.Context) (res BatchListResponse, err error) { + var ( + resp *resty.Response + apiError APIErrorResponse + ) + + req := s.client.request(ctx) + if s.after != nil { + req.SetQueryParam("after", *s.after) + } + if s.limit != nil { + req.SetQueryParam("limit", strconv.Itoa(*s.limit)) + } + + if resp, err = req. + SetResult(&res). + SetError(&apiError). + Get("batches"); err != nil { + return + } + + if resp.IsError() { + err = apiError + } + + return +} diff --git a/zhipu/batch_support.go b/zhipu/batch_support.go new file mode 100644 index 0000000..9427ef4 --- /dev/null +++ b/zhipu/batch_support.go @@ -0,0 +1,63 @@ +package zhipu + +import ( + "encoding/json" + "io" +) + +// BatchSupport is the interface for services with batch support. +type BatchSupport interface { + BatchMethod() string + BatchURL() string + BatchBody() any +} + +// BatchFileWriter is a writer for batch files. +type BatchFileWriter struct { + w io.Writer + je *json.Encoder +} + +// NewBatchFileWriter creates a new BatchFileWriter. +func NewBatchFileWriter(w io.Writer) *BatchFileWriter { + return &BatchFileWriter{w: w, je: json.NewEncoder(w)} +} + +// Write writes a batch file. +func (b *BatchFileWriter) Write(customID string, s BatchSupport) error { + return b.je.Encode(M{ + "custom_id": customID, + "method": s.BatchMethod(), + "url": s.BatchURL(), + "body": s.BatchBody(), + }) +} + +// BatchResultResponse is the response of a batch result. +type BatchResultResponse[T any] struct { + StatusCode int `json:"status_code"` + Body T `json:"body"` +} + +// BatchResult is the result of a batch. +type BatchResult[T any] struct { + ID string `json:"id"` + CustomID string `json:"custom_id"` + Response BatchResultResponse[T] `json:"response"` +} + +// BatchResultReader reads batch results. +type BatchResultReader[T any] struct { + r io.Reader + jd *json.Decoder +} + +// NewBatchResultReader creates a new BatchResultReader. +func NewBatchResultReader[T any](r io.Reader) *BatchResultReader[T] { + return &BatchResultReader[T]{r: r, jd: json.NewDecoder(r)} +} + +// Read reads a batch result. +func (r *BatchResultReader[T]) Read(out *BatchResult[T]) error { + return r.jd.Decode(out) +} diff --git a/zhipu/batch_support_test.go b/zhipu/batch_support_test.go new file mode 100644 index 0000000..59a19c6 --- /dev/null +++ b/zhipu/batch_support_test.go @@ -0,0 +1,73 @@ +package zhipu + +import ( + "bytes" + "io" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBatchFileWriter(t *testing.T) { + client, err := NewClient() + require.NoError(t, err) + + buf := &bytes.Buffer{} + + w := NewBatchFileWriter(buf) + err = w.Write("batch-1", client.ChatCompletion("a").AddMessage(ChatCompletionMessage{ + Role: "user", Content: "hello", + })) + require.NoError(t, err) + err = w.Write("batch-2", client.Embedding("c").SetInput("whoa")) + require.NoError(t, err) + err = w.Write("batch-3", client.ImageGeneration("d").SetPrompt("whoa")) + require.NoError(t, err) + + require.Equal(t, `{"body":{"messages":[{"role":"user","content":"hello"}],"model":"a"},"custom_id":"batch-1","method":"POST","url":"/v4/chat/completions"} +{"body":{"input":"whoa","model":"c"},"custom_id":"batch-2","method":"POST","url":"/v4/embeddings"} +{"body":{"model":"d","prompt":"whoa"},"custom_id":"batch-3","method":"POST","url":"/v4/images/generations"} +`, buf.String()) +} + +func TestBatchResultReader(t *testing.T) { + result := ` + {"response":{"status_code":200,"body":{"created":1715959701,"usage":{"completion_tokens":26,"prompt_tokens":89,"total_tokens":115},"model":"glm-4","id":"8668357533850320547","choices":[{"finish_reason":"stop","index":0,"message":{"role":"assistant","content":"'''json\n{\n \"分类标签\": \"负面\",\n \"特定问题标注\": \"订单处理慢\"\n}\n'''"}}],"request_id":"615-request-1"}},"custom_id":"request-1","id":"batch_1791490810192076800"} +{"response":{"status_code":200,"body":{"created":1715959701,"usage":{"completion_tokens":22,"prompt_tokens":94,"total_tokens":116},"model":"glm-4","id":"8668368425887509080","choices":[{"finish_reason":"stop","index":0,"message":{"role":"assistant","content":"'''json\n{\n \"分类标签\": \"负面\",\n \"特定问题标注\": \"产品缺陷\"\n}\n'''"}}],"request_id":"616-request-2"}},"custom_id":"request-2","id":"batch_1791490810192076800"} +{"response":{"status_code":200,"body":{"created":1715959701,"usage":{"completion_tokens":25,"prompt_tokens":86,"total_tokens":111},"model":"glm-4","id":"8668355815863214980","choices":[{"finish_reason":"stop","index":0,"message":{"role":"assistant","content":"'''json\n{\n \"分类标签\": \"正面\",\n \"特定问题标注\": \"性价比\"\n}\n'''"}}],"request_id":"617-request-3"}},"custom_id":"request-3","id":"batch_1791490810192076800"} +{"response":{"status_code":200,"body":{"created":1715959701,"usage":{"completion_tokens":28,"prompt_tokens":89,"total_tokens":117},"model":"glm-4","id":"8668355815863214981","choices":[{"finish_reason":"stop","index":0,"message":{"role":"assistant","content":"'''json\n{\n \"分类标签\": \"负面\",\n \"特定问题标注\": \"说明文档不清晰\"\n}\n'''"}}],"request_id":"618-request-4"}},"custom_id":"request-4","id":"batch_1791490810192076800"} + +{"response":{"status_code":200,"body":{"created":1715959701,"usage":{"completion_tokens":26,"prompt_tokens":88,"total_tokens":114},"model":"glm-4","id":"8668357533850320546","choices":[{"finish_reason":"stop","index":0,"message":{"role":"assistant","content":"'''json\n{\n \"分类标签\": \"中性\",\n \"特定问题标注\": \"价格问题\"\n}\n'''"}}],"request_id":"619-request-5"}},"custom_id":"request-5","id":"batch_1791490810192076800"} + +{"response":{"status_code":200,"body":{"created":1715959701,"usage":{"completion_tokens":26,"prompt_tokens":90,"total_tokens":116},"model":"glm-4","id":"8668356159460662846","choices":[{"finish_reason":"stop","index":0,"message":{"role":"assistant","content":"'''json\n{\n \"分类标签\": \"负面\",\n \"特定问题标注\": \"配送延迟\"\n}\n'''"}}],"request_id":"620-request-6"}},"custom_id":"request-6","id":"batch_1791490810192076800"} + + +{"response":{"status_code":200,"body":{"created":1715959701,"usage":{"completion_tokens":27,"prompt_tokens":88,"total_tokens":115},"model":"glm-4","id":"8668357671289274638","choices":[{"finish_reason":"stop","index":0,"message":{"role":"assistant","content":"'''json\n{\n \"分类标签\": \"负面\",\n \"特定问题标注\": \"产品描述不符\"\n}\n'''"}}],"request_id":"621-request-7"}},"custom_id":"request-7","id":"batch_1791490810192076800"} +{"response":{"status_code":200,"body":{"created":1715959702,"usage":{"completion_tokens":26,"prompt_tokens":87,"total_tokens":113},"model":"glm-4","id":"8668355644064514872","choices":[{"finish_reason":"stop","index":0,"message":{"role":"assistant","content":"'''json\n{\n \"分类标签\": \"正面\",\n \"特定问题标注\": \"客服态度\"\n}\n'''"}}],"request_id":"622-request-8"}},"custom_id":"request-8","id":"batch_1791490810192076800"} + {"response":{"status_code":200,"body":{"created":1715959701,"usage":{"completion_tokens":29,"prompt_tokens":90,"total_tokens":119},"model":"glm-4","id":"8668357671289274639","choices":[{"finish_reason":"stop","index":0,"message":{"role":"assistant","content":"'''json\n{\n \"分类标签\": \"负面\",\n \"特定问题标注\": \"包装问题, 产品损坏\"\n}\n'''"}}],"request_id":"623-request-9"}},"custom_id":"request-9","id":"batch_1791490810192076800"} +{"response":{"status_code":200,"body":{"created":1715959701,"usage":{"completion_tokens":27,"prompt_tokens":87,"total_tokens":114},"model":"glm-4","id":"8668355644064514871","choices":[{"finish_reason":"stop","index":0,"message":{"role":"assistant","content":"'''json\n{\n \"分类标签\": \"正面\",\n \"特定问题标注\": \"产品描述不符\"\n}\n'''"}}],"request_id":"624-request-10"}},"custom_id":"request-10","id":"batch_1791490810192076800"} +` + + brr := NewBatchResultReader[ChatCompletionResponse](bytes.NewReader([]byte(result))) + + var count int + + for { + var res BatchResult[ChatCompletionResponse] + + err := brr.Read(&res) + + if err != nil { + if err == io.EOF { + err = nil + } + require.Equal(t, 10, count) + require.NoError(t, err) + break + } + + require.Equal(t, 200, res.Response.StatusCode) + + count++ + } +} diff --git a/zhipu/batch_test.go b/zhipu/batch_test.go new file mode 100644 index 0000000..8ce5aa5 --- /dev/null +++ b/zhipu/batch_test.go @@ -0,0 +1,59 @@ +package zhipu + +import ( + "bytes" + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBatchServiceAll(t *testing.T) { + client, err := NewClient() + require.NoError(t, err) + + buf := &bytes.Buffer{} + + bfw := NewBatchFileWriter(buf) + err = bfw.Write("batch_1", client.ChatCompletion("glm-4-flash").AddMessage(ChatCompletionMessage{ + Role: RoleUser, Content: "你好呀", + })) + require.NoError(t, err) + err = bfw.Write("batch_2", client.ChatCompletion("glm-4-flash").AddMessage(ChatCompletionMessage{ + Role: RoleUser, Content: "你叫什么名字", + })) + require.NoError(t, err) + + res, err := client.FileCreate(FilePurposeBatch).SetFile(bytes.NewReader(buf.Bytes()), "batch.jsonl").Do(context.Background()) + require.NoError(t, err) + + fileID := res.FileCreateFineTuneResponse.ID + require.NotEmpty(t, fileID) + + res1, err := client.BatchCreate(). + SetInputFileID(fileID). + SetCompletionWindow(BatchCompletionWindow24h). + SetEndpoint(BatchEndpointV4ChatCompletions).Do(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, res1.ID) + + res2, err := client.BatchGet(res1.ID).Do(context.Background()) + require.NoError(t, err) + require.Equal(t, res2.ID, res1.ID) + + res3, err := client.BatchList().Do(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, res3.Data) + + err = client.BatchCancel(res1.ID).Do(context.Background()) + require.NoError(t, err) +} + +func TestBatchListService(t *testing.T) { + client, err := NewClient() + require.NoError(t, err) + + res, err := client.BatchList().Do(context.Background()) + require.NoError(t, err) + t.Log(res) +} diff --git a/zhipu/chat_completion.go b/zhipu/chat_completion.go new file mode 100644 index 0000000..b00db85 --- /dev/null +++ b/zhipu/chat_completion.go @@ -0,0 +1,577 @@ +package zhipu + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "io" + + "github.com/go-resty/resty/v2" +) + +const ( + RoleSystem = "system" + RoleUser = "user" + RoleAssistant = "assistant" + RoleTool = "tool" + + ToolChoiceAuto = "auto" + + FinishReasonStop = "stop" + FinishReasonStopSequence = "stop_sequence" + FinishReasonToolCalls = "tool_calls" + FinishReasonLength = "length" + FinishReasonSensitive = "sensitive" + FinishReasonNetworkError = "network_error" + + ToolTypeFunction = "function" + ToolTypeWebSearch = "web_search" + ToolTypeRetrieval = "retrieval" + + MultiContentTypeText = "text" + MultiContentTypeImageURL = "image_url" + MultiContentTypeVideoURL = "video_url" + + // New in GLM-4-AllTools + ToolTypeCodeInterpreter = "code_interpreter" + ToolTypeDrawingTool = "drawing_tool" + ToolTypeWebBrowser = "web_browser" + + CodeInterpreterSandboxNone = "none" + CodeInterpreterSandboxAuto = "auto" + + ChatCompletionStatusFailed = "failed" + ChatCompletionStatusCompleted = "completed" + ChatCompletionStatusRequiresAction = "requires_action" +) + +// ChatCompletionTool is the interface for chat completion tool +type ChatCompletionTool interface { + isChatCompletionTool() +} + +// ChatCompletionToolFunction is the function for chat completion +type ChatCompletionToolFunction struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters any `json:"parameters"` +} + +func (ChatCompletionToolFunction) isChatCompletionTool() {} + +// ChatCompletionToolRetrieval is the retrieval for chat completion +type ChatCompletionToolRetrieval struct { + KnowledgeID string `json:"knowledge_id"` + PromptTemplate string `json:"prompt_template,omitempty"` +} + +func (ChatCompletionToolRetrieval) isChatCompletionTool() {} + +// ChatCompletionToolWebSearch is the web search for chat completion +type ChatCompletionToolWebSearch struct { + Enable *bool `json:"enable,omitempty"` + SearchQuery string `json:"search_query,omitempty"` + SearchResult bool `json:"search_result,omitempty"` +} + +func (ChatCompletionToolWebSearch) isChatCompletionTool() {} + +// ChatCompletionToolCodeInterpreter is the code interpreter for chat completion +// only in GLM-4-AllTools +type ChatCompletionToolCodeInterpreter struct { + Sandbox *string `json:"sandbox,omitempty"` +} + +func (ChatCompletionToolCodeInterpreter) isChatCompletionTool() {} + +// ChatCompletionToolDrawingTool is the drawing tool for chat completion +// only in GLM-4-AllTools +type ChatCompletionToolDrawingTool struct { + // no fields +} + +func (ChatCompletionToolDrawingTool) isChatCompletionTool() {} + +// ChatCompletionToolWebBrowser is the web browser for chat completion +type ChatCompletionToolWebBrowser struct { + // no fields +} + +func (ChatCompletionToolWebBrowser) isChatCompletionTool() {} + +// ChatCompletionUsage is the usage for chat completion +type ChatCompletionUsage struct { + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + TotalTokens int64 `json:"total_tokens"` +} + +// ChatCompletionWebSearch is the web search result for chat completion +type ChatCompletionWebSearch struct { + Icon string `json:"icon"` + Title string `json:"title"` + Link string `json:"link"` + Media string `json:"media"` + Content string `json:"content"` +} + +// ChatCompletionToolCallFunction is the function for chat completion tool call +type ChatCompletionToolCallFunction struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` +} + +// ChatCompletionToolCallCodeInterpreterOutput is the output for chat completion tool call code interpreter +type ChatCompletionToolCallCodeInterpreterOutput struct { + Type string `json:"type"` + Logs string `json:"logs"` + File string `json:"file"` +} + +// ChatCompletionToolCallCodeInterpreter is the code interpreter for chat completion tool call +type ChatCompletionToolCallCodeInterpreter struct { + Input string `json:"input"` + Outputs []ChatCompletionToolCallCodeInterpreterOutput `json:"outputs"` +} + +// ChatCompletionToolCallDrawingToolOutput is the output for chat completion tool call drawing tool +type ChatCompletionToolCallDrawingToolOutput struct { + Image string `json:"image"` +} + +// ChatCompletionToolCallDrawingTool is the drawing tool for chat completion tool call +type ChatCompletionToolCallDrawingTool struct { + Input string `json:"input"` + Outputs []ChatCompletionToolCallDrawingToolOutput `json:"outputs"` +} + +// ChatCompletionToolCallWebBrowserOutput is the output for chat completion tool call web browser +type ChatCompletionToolCallWebBrowserOutput struct { + Title string `json:"title"` + Link string `json:"link"` + Content string `json:"content"` +} + +// ChatCompletionToolCallWebBrowser is the web browser for chat completion tool call +type ChatCompletionToolCallWebBrowser struct { + Input string `json:"input"` + Outputs []ChatCompletionToolCallWebBrowserOutput `json:"outputs"` +} + +// ChatCompletionToolCall is the tool call for chat completion +type ChatCompletionToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function *ChatCompletionToolCallFunction `json:"function,omitempty"` + CodeInterpreter *ChatCompletionToolCallCodeInterpreter `json:"code_interpreter,omitempty"` + DrawingTool *ChatCompletionToolCallDrawingTool `json:"drawing_tool,omitempty"` + WebBrowser *ChatCompletionToolCallWebBrowser `json:"web_browser,omitempty"` +} + +type ChatCompletionMessageType interface { + isChatCompletionMessageType() +} + +// ChatCompletionMessage is the message for chat completion +type ChatCompletionMessage struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolCalls []ChatCompletionToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +func (ChatCompletionMessage) isChatCompletionMessageType() {} + +type ChatCompletionMultiContent struct { + Type string `json:"type"` + Text string `json:"text"` + ImageURL *URLItem `json:"image_url,omitempty"` + VideoURL *URLItem `json:"video_url,omitempty"` +} + +// ChatCompletionMultiMessage is the multi message for chat completion +type ChatCompletionMultiMessage struct { + Role string `json:"role"` + Content []ChatCompletionMultiContent `json:"content"` +} + +func (ChatCompletionMultiMessage) isChatCompletionMessageType() {} + +// ChatCompletionMeta is the meta for chat completion +type ChatCompletionMeta struct { + UserInfo string `json:"user_info"` + BotInfo string `json:"bot_info"` + UserName string `json:"user_name"` + BotName string `json:"bot_name"` +} + +// ChatCompletionChoice is the choice for chat completion +type ChatCompletionChoice struct { + Index int `json:"index"` + FinishReason string `json:"finish_reason"` + Delta ChatCompletionMessage `json:"delta"` // stream mode + Message ChatCompletionMessage `json:"message"` // non-stream mode +} + +// ChatCompletionResponse is the response for chat completion +type ChatCompletionResponse struct { + ID string `json:"id"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []ChatCompletionChoice `json:"choices"` + Usage ChatCompletionUsage `json:"usage"` + WebSearch []ChatCompletionWebSearch `json:"web_search"` + // Status is the status of the chat completion, only in GLM-4-AllTools + Status string `json:"status"` +} + +// ChatCompletionStreamHandler is the handler for chat completion stream +type ChatCompletionStreamHandler func(chunk ChatCompletionResponse) error + +var ( + chatCompletionStreamPrefix = []byte("data:") + chatCompletionStreamDone = []byte("[DONE]") +) + +// chatCompletionReduceResponse reduce the chunk to the response +func chatCompletionReduceResponse(out *ChatCompletionResponse, chunk ChatCompletionResponse) { + if len(out.Choices) == 0 { + out.Choices = append(out.Choices, ChatCompletionChoice{}) + } + + // basic + out.ID = chunk.ID + out.Created = chunk.Created + out.Model = chunk.Model + + // choices + if len(chunk.Choices) != 0 { + oc := &out.Choices[0] + cc := chunk.Choices[0] + + oc.Index = cc.Index + if cc.Delta.Role != "" { + oc.Message.Role = cc.Delta.Role + } + oc.Message.Content += cc.Delta.Content + oc.Message.ToolCalls = append(oc.Message.ToolCalls, cc.Delta.ToolCalls...) + if cc.FinishReason != "" { + oc.FinishReason = cc.FinishReason + } + } + + // usage + if chunk.Usage.CompletionTokens != 0 { + out.Usage.CompletionTokens = chunk.Usage.CompletionTokens + } + if chunk.Usage.PromptTokens != 0 { + out.Usage.PromptTokens = chunk.Usage.PromptTokens + } + if chunk.Usage.TotalTokens != 0 { + out.Usage.TotalTokens = chunk.Usage.TotalTokens + } + + // web search + out.WebSearch = append(out.WebSearch, chunk.WebSearch...) +} + +// chatCompletionDecodeStream decode the sse stream of chat completion +func chatCompletionDecodeStream(r io.Reader, fn func(chunk ChatCompletionResponse) error) (err error) { + br := bufio.NewReader(r) + + for { + var line []byte + + if line, err = br.ReadBytes('\n'); err != nil { + if errors.Is(err, io.EOF) { + err = nil + } + break + } + + line = bytes.TrimSpace(line) + + if len(line) == 0 { + continue + } + + if !bytes.HasPrefix(line, chatCompletionStreamPrefix) { + continue + } + + data := bytes.TrimSpace(line[len(chatCompletionStreamPrefix):]) + + if bytes.Equal(data, chatCompletionStreamDone) { + break + } + + if len(data) == 0 { + continue + } + + var chunk ChatCompletionResponse + if err = json.Unmarshal(data, &chunk); err != nil { + return + } + if err = fn(chunk); err != nil { + return + } + } + + return +} + +// ChatCompletionStreamService is the service for chat completion stream +type ChatCompletionService struct { + client *Client + + model string + requestID *string + doSample *bool + temperature *float64 + topP *float64 + maxTokens *int + stop []string + toolChoice *string + userID *string + meta *ChatCompletionMeta + + messages []any + tools []any + + streamHandler ChatCompletionStreamHandler +} + +var ( + _ BatchSupport = &ChatCompletionService{} +) + +// NewChatCompletionService creates a new ChatCompletionService. +func NewChatCompletionService(client *Client) *ChatCompletionService { + return &ChatCompletionService{ + client: client, + } +} + +func (s *ChatCompletionService) BatchMethod() string { + return "POST" +} + +func (s *ChatCompletionService) BatchURL() string { + return BatchEndpointV4ChatCompletions +} + +func (s *ChatCompletionService) BatchBody() any { + return s.buildBody() +} + +// SetModel set the model of the chat completion +func (s *ChatCompletionService) SetModel(model string) *ChatCompletionService { + s.model = model + return s +} + +// SetMeta set the meta of the chat completion, optional +func (s *ChatCompletionService) SetMeta(meta ChatCompletionMeta) *ChatCompletionService { + s.meta = &meta + return s +} + +// SetRequestID set the request id of the chat completion, optional +func (s *ChatCompletionService) SetRequestID(requestID string) *ChatCompletionService { + s.requestID = &requestID + return s +} + +// SetTemperature set the temperature of the chat completion, optional +func (s *ChatCompletionService) SetDoSample(doSample bool) *ChatCompletionService { + s.doSample = &doSample + return s +} + +// SetTemperature set the temperature of the chat completion, optional +func (s *ChatCompletionService) SetTemperature(temperature float64) *ChatCompletionService { + s.temperature = &temperature + return s +} + +// SetTopP set the top p of the chat completion, optional +func (s *ChatCompletionService) SetTopP(topP float64) *ChatCompletionService { + s.topP = &topP + return s +} + +// SetMaxTokens set the max tokens of the chat completion, optional +func (s *ChatCompletionService) SetMaxTokens(maxTokens int) *ChatCompletionService { + s.maxTokens = &maxTokens + return s +} + +// SetStop set the stop of the chat completion, optional +func (s *ChatCompletionService) SetStop(stop ...string) *ChatCompletionService { + s.stop = stop + return s +} + +// SetToolChoice set the tool choice of the chat completion, optional +func (s *ChatCompletionService) SetToolChoice(toolChoice string) *ChatCompletionService { + s.toolChoice = &toolChoice + return s +} + +// SetUserID set the user id of the chat completion, optional +func (s *ChatCompletionService) SetUserID(userID string) *ChatCompletionService { + s.userID = &userID + return s +} + +// SetStreamHandler set the stream handler of the chat completion, optional +// this will enable the stream mode +func (s *ChatCompletionService) SetStreamHandler(handler ChatCompletionStreamHandler) *ChatCompletionService { + s.streamHandler = handler + return s +} + +// AddMessage add the message to the chat completion +func (s *ChatCompletionService) AddMessage(messages ...ChatCompletionMessageType) *ChatCompletionService { + for _, message := range messages { + s.messages = append(s.messages, message) + } + return s +} + +// AddFunction add the function to the chat completion +func (s *ChatCompletionService) AddTool(tools ...ChatCompletionTool) *ChatCompletionService { + for _, tool := range tools { + switch tool := tool.(type) { + case ChatCompletionToolFunction: + s.tools = append(s.tools, map[string]any{ + "type": ToolTypeFunction, + ToolTypeFunction: tool, + }) + case ChatCompletionToolRetrieval: + s.tools = append(s.tools, map[string]any{ + "type": ToolTypeRetrieval, + ToolTypeRetrieval: tool, + }) + case ChatCompletionToolWebSearch: + s.tools = append(s.tools, map[string]any{ + "type": ToolTypeWebSearch, + ToolTypeWebSearch: tool, + }) + case ChatCompletionToolCodeInterpreter: + s.tools = append(s.tools, map[string]any{ + "type": ToolTypeCodeInterpreter, + ToolTypeCodeInterpreter: tool, + }) + case ChatCompletionToolDrawingTool: + s.tools = append(s.tools, map[string]any{ + "type": ToolTypeDrawingTool, + ToolTypeDrawingTool: tool, + }) + case ChatCompletionToolWebBrowser: + s.tools = append(s.tools, map[string]any{ + "type": ToolTypeWebBrowser, + ToolTypeWebBrowser: tool, + }) + } + } + return s +} + +func (s *ChatCompletionService) buildBody() M { + body := map[string]any{ + "model": s.model, + "messages": s.messages, + } + if s.requestID != nil { + body["request_id"] = *s.requestID + } + if s.doSample != nil { + body["do_sample"] = *s.doSample + } + if s.temperature != nil { + body["temperature"] = *s.temperature + } + if s.topP != nil { + body["top_p"] = *s.topP + } + if s.maxTokens != nil { + body["max_tokens"] = *s.maxTokens + } + if len(s.stop) != 0 { + body["stop"] = s.stop + } + if len(s.tools) != 0 { + body["tools"] = s.tools + } + if s.toolChoice != nil { + body["tool_choice"] = *s.toolChoice + } + if s.userID != nil { + body["user_id"] = *s.userID + } + if s.meta != nil { + body["meta"] = s.meta + } + return body +} + +// Do send the request of the chat completion and return the response +func (s *ChatCompletionService) Do(ctx context.Context) (res ChatCompletionResponse, err error) { + body := s.buildBody() + + streamHandler := s.streamHandler + + if streamHandler == nil { + var ( + resp *resty.Response + apiError APIErrorResponse + ) + //fmt.Println(u.BMagenta(u.JsonP(body)), 111) + if resp, err = s.client.request(ctx).SetBody(body).SetResult(&res).SetError(&apiError).Post("chat/completions"); err != nil { + //fmt.Println(u.BRed(err.Error()), 2221) + return + } + if resp.IsError() { + err = apiError + //fmt.Println(u.BRed(err.Error()), 2222) + return + } + //fmt.Println(u.BGreen(u.JsonP(resp.Result())), resp.Status(), resp.Status(), 333) + return + } + + // stream mode + + body["stream"] = true + + var resp *resty.Response + + if resp, err = s.client.request(ctx).SetBody(body).SetDoNotParseResponse(true).Post("chat/completions"); err != nil { + return + } + defer resp.RawBody().Close() + + if resp.IsError() { + err = errors.New(resp.Status()) + return + } + + var choice ChatCompletionChoice + + if err = chatCompletionDecodeStream(resp.RawBody(), func(chunk ChatCompletionResponse) error { + // reduce the chunk to the response + chatCompletionReduceResponse(&res, chunk) + // invoke the stream handler + return streamHandler(chunk) + }); err != nil { + return + } + + res.Choices = append(res.Choices, choice) + + return +} diff --git a/zhipu/chat_completion_test.go b/zhipu/chat_completion_test.go new file mode 100644 index 0000000..8839850 --- /dev/null +++ b/zhipu/chat_completion_test.go @@ -0,0 +1,251 @@ +package zhipu + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestChatCompletionService(t *testing.T) { + client, err := NewClient() + require.NoError(t, err) + + s := client.ChatCompletion("glm-4-flash") + s.AddMessage(ChatCompletionMessage{ + Role: RoleUser, + Content: "你好呀", + }) + res, err := s.Do(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, res.Choices) + choice := res.Choices[0] + require.Equal(t, FinishReasonStop, choice.FinishReason) + require.NotEmpty(t, choice.Message.Content) +} + +func TestChatCompletionServiceCharGLM(t *testing.T) { + client, err := NewClient() + require.NoError(t, err) + + s := client.ChatCompletion("charglm-3") + s.SetMeta( + ChatCompletionMeta{ + UserName: "啵酱", + UserInfo: "啵酱是小少爷", + BotName: "塞巴斯酱", + BotInfo: "塞巴斯酱是一个冷酷的恶魔管家", + }, + ).AddMessage(ChatCompletionMessage{ + Role: RoleUser, + Content: "早上好", + }) + res, err := s.Do(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, res.Choices) + choice := res.Choices[0] + require.Contains(t, []string{FinishReasonStop, FinishReasonStopSequence}, choice.FinishReason) + require.NotEmpty(t, choice.Message.Content) +} + +func TestChatCompletionServiceAllToolsCodeInterpreter(t *testing.T) { + client, err := NewClient() + require.NoError(t, err) + + s := client.ChatCompletion("GLM-4-AllTools") + s.AddMessage(ChatCompletionMultiMessage{ + Role: "user", + Content: []ChatCompletionMultiContent{ + { + Type: "text", + Text: "计算[5,10,20,700,99,310,978,100]的平均值和方差。", + }, + }, + }) + s.AddTool(ChatCompletionToolCodeInterpreter{ + Sandbox: Ptr(CodeInterpreterSandboxAuto), + }) + + foundInterpreterInput := false + foundInterpreterOutput := false + + s.SetStreamHandler(func(chunk ChatCompletionResponse) error { + for _, c := range chunk.Choices { + for _, tc := range c.Delta.ToolCalls { + if tc.Type == ToolTypeCodeInterpreter && tc.CodeInterpreter != nil { + if tc.CodeInterpreter.Input != "" { + foundInterpreterInput = true + } + if len(tc.CodeInterpreter.Outputs) > 0 { + foundInterpreterOutput = true + } + } + } + } + buf, _ := json.MarshalIndent(chunk, "", " ") + t.Log(string(buf)) + return nil + }) + + res, err := s.Do(context.Background()) + require.True(t, foundInterpreterInput) + require.True(t, foundInterpreterOutput) + require.NotNil(t, res) + require.NoError(t, err) +} + +func TestChatCompletionServiceAllToolsDrawingTool(t *testing.T) { + client, err := NewClient() + require.NoError(t, err) + + s := client.ChatCompletion("GLM-4-AllTools") + s.AddMessage(ChatCompletionMultiMessage{ + Role: "user", + Content: []ChatCompletionMultiContent{ + { + Type: "text", + Text: "画一个正弦函数图像", + }, + }, + }) + s.AddTool(ChatCompletionToolDrawingTool{}) + + foundInput := false + foundOutput := false + outputImage := "" + + s.SetStreamHandler(func(chunk ChatCompletionResponse) error { + for _, c := range chunk.Choices { + for _, tc := range c.Delta.ToolCalls { + if tc.Type == ToolTypeDrawingTool && tc.DrawingTool != nil { + if tc.DrawingTool.Input != "" { + foundInput = true + } + if len(tc.DrawingTool.Outputs) > 0 { + foundOutput = true + } + for _, output := range tc.DrawingTool.Outputs { + if output.Image != "" { + outputImage = output.Image + } + } + } + } + } + buf, _ := json.MarshalIndent(chunk, "", " ") + t.Log(string(buf)) + return nil + }) + + res, err := s.Do(context.Background()) + require.True(t, foundInput) + require.True(t, foundOutput) + require.NotEmpty(t, outputImage) + t.Log(outputImage) + require.NotNil(t, res) + require.NoError(t, err) +} + +func TestChatCompletionServiceAllToolsWebBrowser(t *testing.T) { + client, err := NewClient() + require.NoError(t, err) + + s := client.ChatCompletion("GLM-4-AllTools") + s.AddMessage(ChatCompletionMultiMessage{ + Role: "user", + Content: []ChatCompletionMultiContent{ + { + Type: "text", + Text: "搜索下本周深圳天气如何", + }, + }, + }) + s.AddTool(ChatCompletionToolWebBrowser{}) + + foundInput := false + foundOutput := false + outputContent := "" + + s.SetStreamHandler(func(chunk ChatCompletionResponse) error { + for _, c := range chunk.Choices { + for _, tc := range c.Delta.ToolCalls { + if tc.Type == ToolTypeWebBrowser && tc.WebBrowser != nil { + if tc.WebBrowser.Input != "" { + foundInput = true + } + if len(tc.WebBrowser.Outputs) > 0 { + foundOutput = true + } + for _, output := range tc.WebBrowser.Outputs { + if output.Content != "" { + outputContent = output.Content + } + } + } + } + } + buf, _ := json.MarshalIndent(chunk, "", " ") + t.Log(string(buf)) + return nil + }) + + res, err := s.Do(context.Background()) + require.True(t, foundInput) + require.True(t, foundOutput) + require.NotEmpty(t, outputContent) + t.Log(outputContent) + require.NotNil(t, res) + require.NoError(t, err) +} + +func TestChatCompletionServiceStream(t *testing.T) { + client, err := NewClient() + require.NoError(t, err) + + var content string + + s := client.ChatCompletion("glm-4-flash").AddMessage(ChatCompletionMessage{ + Role: RoleUser, + Content: "你好呀", + }).SetStreamHandler(func(chunk ChatCompletionResponse) error { + content += chunk.Choices[0].Delta.Content + return nil + }) + res, err := s.Do(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, res.Choices) + choice := res.Choices[0] + require.Equal(t, FinishReasonStop, choice.FinishReason) + require.NotEmpty(t, choice.Message.Content) + require.Equal(t, content, choice.Message.Content) +} + +func TestChatCompletionServiceVision(t *testing.T) { + client, err := NewClient() + require.NoError(t, err) + + s := client.ChatCompletion("glm-4v") + s.AddMessage(ChatCompletionMultiMessage{ + Role: RoleUser, + Content: []ChatCompletionMultiContent{ + { + Type: MultiContentTypeText, + Text: "图里有什么", + }, + { + Type: MultiContentTypeImageURL, + ImageURL: &URLItem{ + URL: "https://img1.baidu.com/it/u=1369931113,3388870256&fm=253&app=138&size=w931&n=0&f=JPEG&fmt=auto?sec=1703696400&t=f3028c7a1dca43a080aeb8239f09cc2f", + }, + }, + }, + }) + res, err := s.Do(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, res.Choices) + require.NotZero(t, res.Usage.CompletionTokens) + choice := res.Choices[0] + require.Equal(t, FinishReasonStop, choice.FinishReason) + require.NotEmpty(t, choice.Message.Content) +} diff --git a/zhipu/client.go b/zhipu/client.go new file mode 100644 index 0000000..5f2aa9b --- /dev/null +++ b/zhipu/client.go @@ -0,0 +1,291 @@ +package zhipu + +import ( + "context" + "errors" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/go-resty/resty/v2" + "github.com/golang-jwt/jwt/v5" +) + +const ( + envAPIKey = "ZHIPUAI_API_KEY" + envBaseURL = "ZHIPUAI_BASE_URL" + envDebug = "ZHIPUAI_DEBUG" + + defaultBaseURL = "https://open.bigmodel.cn/api/paas/v4" +) + +var ( + // ErrAPIKeyMissing is the error when the api key is missing + ErrAPIKeyMissing = errors.New("zhipu: api key is missing") + // ErrAPIKeyMalformed is the error when the api key is malformed + ErrAPIKeyMalformed = errors.New("zhipu: api key is malformed") +) + +type clientOptions struct { + baseURL string + apiKey string + client *http.Client + debug *bool +} + +// ClientOption is a function that configures the client +type ClientOption func(opts *clientOptions) + +// WithAPIKey set the api key of the client +func WithAPIKey(apiKey string) ClientOption { + return func(opts *clientOptions) { + opts.apiKey = apiKey + } +} + +// WithBaseURL set the base url of the client +func WithBaseURL(baseURL string) ClientOption { + return func(opts *clientOptions) { + opts.baseURL = baseURL + } +} + +// WithHTTPClient set the http client of the client +func WithHTTPClient(client *http.Client) ClientOption { + return func(opts *clientOptions) { + opts.client = client + } +} + +// WithDebug set the debug mode of the client +func WithDebug(debug bool) ClientOption { + return func(opts *clientOptions) { + opts.debug = new(bool) + *opts.debug = debug + } +} + +// Client is the client for zhipu ai platform +type Client struct { + client *resty.Client + debug bool + keyID string + keySecret []byte +} + +func (c *Client) createJWT() string { + timestamp := time.Now().UnixMilli() + exp := timestamp + time.Hour.Milliseconds()*24*7 + + t := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "api_key": c.keyID, + "timestamp": timestamp, + "exp": exp, + }) + t.Header = map[string]interface{}{ + "alg": "HS256", + "sign_type": "SIGN", + } + + token, err := t.SignedString(c.keySecret) + if err != nil { + panic(err) + } + return token +} + +// request creates a new resty request with the jwt token and context +func (c *Client) request(ctx context.Context) *resty.Request { + return c.client.R().SetContext(ctx).SetHeader("Authorization", c.createJWT()) +} + +// NewClient creates a new client +// It will read the api key from the environment variable ZHIPUAI_API_KEY +// It will read the base url from the environment variable ZHIPUAI_BASE_URL +func NewClient(optFns ...ClientOption) (client *Client, err error) { + var opts clientOptions + for _, optFn := range optFns { + optFn(&opts) + } + // base url + if opts.baseURL == "" { + opts.baseURL = strings.TrimSpace(os.Getenv(envBaseURL)) + } + if opts.baseURL == "" { + opts.baseURL = defaultBaseURL + } + // api key + if opts.apiKey == "" { + opts.apiKey = strings.TrimSpace(os.Getenv(envAPIKey)) + } + if opts.apiKey == "" { + err = ErrAPIKeyMissing + return + } + // debug + if opts.debug == nil { + if debugStr := strings.TrimSpace(os.Getenv(envDebug)); debugStr != "" { + if debug, err1 := strconv.ParseBool(debugStr); err1 == nil { + opts.debug = &debug + } + } + } + + keyComponents := strings.SplitN(opts.apiKey, ".", 2) + + if len(keyComponents) != 2 { + err = ErrAPIKeyMalformed + return + } + + client = &Client{ + keyID: keyComponents[0], + keySecret: []byte(keyComponents[1]), + } + + if opts.client == nil { + client.client = resty.New() + } else { + client.client = resty.NewWithClient(opts.client) + } + + client.client = client.client.SetBaseURL(opts.baseURL) + + if opts.debug != nil { + client.client.SetDebug(*opts.debug) + client.debug = *opts.debug + } + return +} + +// BatchCreate creates a new BatchCreateService. +func (c *Client) BatchCreate() *BatchCreateService { + return NewBatchCreateService(c) +} + +// BatchGet creates a new BatchGetService. +func (c *Client) BatchGet(batchID string) *BatchGetService { + return NewBatchGetService(c).SetBatchID(batchID) +} + +// BatchCancel creates a new BatchCancelService. +func (c *Client) BatchCancel(batchID string) *BatchCancelService { + return NewBatchCancelService(c).SetBatchID(batchID) +} + +// BatchList creates a new BatchListService. +func (c *Client) BatchList() *BatchListService { + return NewBatchListService(c) +} + +// ChatCompletion creates a new ChatCompletionService. +func (c *Client) ChatCompletion(model string) *ChatCompletionService { + return NewChatCompletionService(c).SetModel(model) +} + +// Embedding embeds a list of text into a vector space. +func (c *Client) Embedding(model string) *EmbeddingService { + return NewEmbeddingService(c).SetModel(model) +} + +// FileCreate creates a new FileCreateService. +func (c *Client) FileCreate(purpose string) *FileCreateService { + return NewFileCreateService(c).SetPurpose(purpose) +} + +// FileEditService creates a new FileEditService. +func (c *Client) FileEdit(documentID string) *FileEditService { + return NewFileEditService(c).SetDocumentID(documentID) +} + +// FileList creates a new FileListService. +func (c *Client) FileList(purpose string) *FileListService { + return NewFileListService(c).SetPurpose(purpose) +} + +// FileDeleteService creates a new FileDeleteService. +func (c *Client) FileDelete(documentID string) *FileDeleteService { + return NewFileDeleteService(c).SetDocumentID(documentID) +} + +// FileGetService creates a new FileGetService. +func (c *Client) FileGet(documentID string) *FileGetService { + return NewFileGetService(c).SetDocumentID(documentID) +} + +// FileDownload creates a new FileDownloadService. +func (c *Client) FileDownload(fileID string) *FileDownloadService { + return NewFileDownloadService(c).SetFileID(fileID) +} + +// FineTuneCreate creates a new fine tune create service +func (c *Client) FineTuneCreate(model string) *FineTuneCreateService { + return NewFineTuneCreateService(c).SetModel(model) +} + +// FineTuneEventList creates a new fine tune event list service +func (c *Client) FineTuneEventList(jobID string) *FineTuneEventListService { + return NewFineTuneEventListService(c).SetJobID(jobID) +} + +// FineTuneGet creates a new fine tune get service +func (c *Client) FineTuneGet(jobID string) *FineTuneGetService { + return NewFineTuneGetService(c).SetJobID(jobID) +} + +// FineTuneList creates a new fine tune list service +func (c *Client) FineTuneList() *FineTuneListService { + return NewFineTuneListService(c) +} + +// FineTuneDelete creates a new fine tune delete service +func (c *Client) FineTuneDelete(jobID string) *FineTuneDeleteService { + return NewFineTuneDeleteService(c).SetJobID(jobID) +} + +// FineTuneCancel creates a new fine tune cancel service +func (c *Client) FineTuneCancel(jobID string) *FineTuneCancelService { + return NewFineTuneCancelService(c).SetJobID(jobID) +} + +// ImageGeneration creates a new image generation service +func (c *Client) ImageGeneration(model string) *ImageGenerationService { + return NewImageGenerationService(c).SetModel(model) +} + +// KnowledgeCreate creates a new knowledge create service +func (c *Client) KnowledgeCreate() *KnowledgeCreateService { + return NewKnowledgeCreateService(c) +} + +// KnowledgeEdit creates a new knowledge edit service +func (c *Client) KnowledgeEdit(knowledgeID string) *KnowledgeEditService { + return NewKnowledgeEditService(c).SetKnowledgeID(knowledgeID) +} + +// KnowledgeList list all the knowledge +func (c *Client) KnowledgeList() *KnowledgeListService { + return NewKnowledgeListService(c) +} + +// KnowledgeDelete creates a new knowledge delete service +func (c *Client) KnowledgeDelete(knowledgeID string) *KnowledgeDeleteService { + return NewKnowledgeDeleteService(c).SetKnowledgeID(knowledgeID) +} + +// KnowledgeGet creates a new knowledge get service +func (c *Client) KnowledgeCapacity() *KnowledgeCapacityService { + return NewKnowledgeCapacityService(c) +} + +// VideoGeneration creates a new video generation service +func (c *Client) VideoGeneration(model string) *VideoGenerationService { + return NewVideoGenerationService(c).SetModel(model) +} + +// AsyncResult creates a new async result get service +func (c *Client) AsyncResult(id string) *AsyncResultService { + return NewAsyncResultService(c).SetID(id) +} diff --git a/zhipu/client_test.go b/zhipu/client_test.go new file mode 100644 index 0000000..dd4800d --- /dev/null +++ b/zhipu/client_test.go @@ -0,0 +1,17 @@ +package zhipu + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestClientR(t *testing.T) { + c, err := NewClient() + require.NoError(t, err) + // the only free api is to list fine-tuning jobs + res, err := c.request(context.Background()).Get("fine_tuning/jobs") + require.NoError(t, err) + require.True(t, res.IsSuccess()) +} diff --git a/zhipu/cog.toml b/zhipu/cog.toml new file mode 100644 index 0000000..ee92ed8 --- /dev/null +++ b/zhipu/cog.toml @@ -0,0 +1,25 @@ +from_latest_tag = false +ignore_merge_commits = false +disable_changelog = false +disable_bump_commit = false +generate_mono_repository_global_tag = true +branch_whitelist = [] +skip_ci = "[skip ci]" +skip_untracked = false +pre_bump_hooks = [] +post_bump_hooks = [] +pre_package_bump_hooks = [] +post_package_bump_hooks = [] +tag_prefix = "v" + +[git_hooks] + +[commit_types] + +[changelog] +path = "CHANGELOG.md" +authors = [] + +[bump_profiles] + +[packages] diff --git a/zhipu/embedding.go b/zhipu/embedding.go new file mode 100644 index 0000000..45c672b --- /dev/null +++ b/zhipu/embedding.go @@ -0,0 +1,87 @@ +package zhipu + +import ( + "context" + + "github.com/go-resty/resty/v2" +) + +// EmbeddingData is the data for each embedding. +type EmbeddingData struct { + Embedding []float64 `json:"embedding"` + Index int `json:"index"` + Object string `json:"object"` +} + +// EmbeddingResponse is the response from the embedding service. +type EmbeddingResponse struct { + Model string `json:"model"` + Data []EmbeddingData `json:"data"` + Object string `json:"object"` + Usage ChatCompletionUsage `json:"usage"` +} + +// EmbeddingService embeds a list of text into a vector space. +type EmbeddingService struct { + client *Client + + model string + input string +} + +var ( + _ BatchSupport = &EmbeddingService{} +) + +// NewEmbeddingService creates a new EmbeddingService. +func NewEmbeddingService(client *Client) *EmbeddingService { + return &EmbeddingService{client: client} +} + +func (s *EmbeddingService) BatchMethod() string { + return "POST" +} + +func (s *EmbeddingService) BatchURL() string { + return BatchEndpointV4Embeddings +} + +func (s *EmbeddingService) BatchBody() any { + return s.buildBody() +} + +// SetModel sets the model to use for the embedding. +func (s *EmbeddingService) SetModel(model string) *EmbeddingService { + s.model = model + return s +} + +// SetInput sets the input text to embed. +func (s *EmbeddingService) SetInput(input string) *EmbeddingService { + s.input = input + return s +} + +func (s *EmbeddingService) buildBody() M { + return M{"model": s.model, "input": s.input} +} + +func (s *EmbeddingService) Do(ctx context.Context) (res EmbeddingResponse, err error) { + var ( + resp *resty.Response + apiError APIErrorResponse + ) + + if resp, err = s.client.request(ctx). + SetBody(s.buildBody()). + SetResult(&res). + SetError(&apiError). + Post("embeddings"); err != nil { + return + } + if resp.IsError() { + err = apiError + return + } + return +} diff --git a/zhipu/embedding_test.go b/zhipu/embedding_test.go new file mode 100644 index 0000000..46f4aeb --- /dev/null +++ b/zhipu/embedding_test.go @@ -0,0 +1,21 @@ +package zhipu + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEmbeddingService(t *testing.T) { + client, err := NewClient() + require.NoError(t, err) + + service := client.Embedding("embedding-2") + + resp, err := service.SetInput("你好").Do(context.Background()) + require.NoError(t, err) + require.NotZero(t, resp.Usage.TotalTokens) + require.NotEmpty(t, resp.Data) + require.NotEmpty(t, resp.Data[0].Embedding) +} diff --git a/zhipu/error.go b/zhipu/error.go new file mode 100644 index 0000000..ea07ad7 --- /dev/null +++ b/zhipu/error.go @@ -0,0 +1,58 @@ +package zhipu + +type APIError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func (e APIError) Error() string { + return e.Message +} + +type APIErrorResponse struct { + APIError `json:"error"` +} + +func (e APIErrorResponse) Error() string { + return e.APIError.Error() +} + +// GetAPIErrorCode returns the error code of an API error. +func GetAPIErrorCode(err error) string { + if err == nil { + return "" + } + if e, ok := err.(APIError); ok { + return e.Code + } + if e, ok := err.(APIErrorResponse); ok { + return e.Code + } + if e, ok := err.(*APIError); ok && e != nil { + return e.Code + } + if e, ok := err.(*APIErrorResponse); ok && e != nil { + return e.Code + } + return "" +} + +// GetAPIErrorMessage returns the error message of an API error. +func GetAPIErrorMessage(err error) string { + if err == nil { + return "" + } + if e, ok := err.(APIError); ok { + return e.Message + } + if e, ok := err.(APIErrorResponse); ok { + return e.Message + } + if e, ok := err.(*APIError); ok && e != nil { + return e.Message + } + if e, ok := err.(*APIErrorResponse); ok && e != nil { + return e.Message + } + return err.Error() +} diff --git a/zhipu/error_test.go b/zhipu/error_test.go new file mode 100644 index 0000000..3f2fa08 --- /dev/null +++ b/zhipu/error_test.go @@ -0,0 +1,38 @@ +package zhipu + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAPIError(t *testing.T) { + err := APIError{ + Code: "code", + Message: "message", + } + require.Equal(t, "message", err.Error()) + require.Equal(t, "code", GetAPIErrorCode(err)) + require.Equal(t, "message", GetAPIErrorMessage(err)) +} + +func TestAPIErrorResponse(t *testing.T) { + err := APIErrorResponse{ + APIError: APIError{ + Code: "code", + Message: "message", + }, + } + require.Equal(t, "message", err.Error()) + require.Equal(t, "code", GetAPIErrorCode(err)) + require.Equal(t, "message", GetAPIErrorMessage(err)) +} + +func TestAPIErrorResponseFromDoc(t *testing.T) { + var res APIErrorResponse + err := json.Unmarshal([]byte(`{"error":{"code":"1002","message":"Authorization Token非法,请确认Authorization Token正确传递。"}}`), &res) + require.NoError(t, err) + require.Equal(t, "1002", res.Code) + require.Equal(t, "1002", GetAPIErrorCode(res)) +} diff --git a/zhipu/file.go b/zhipu/file.go new file mode 100644 index 0000000..e8b66af --- /dev/null +++ b/zhipu/file.go @@ -0,0 +1,541 @@ +package zhipu + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "strconv" + + "github.com/go-resty/resty/v2" +) + +const ( + FilePurposeFineTune = "fine-tune" + FilePurposeRetrieval = "retrieval" + FilePurposeBatch = "batch" + + KnowledgeTypeArticle = 1 + KnowledgeTypeQADocument = 2 + KnowledgeTypeQASpreadsheet = 3 + KnowledgeTypeProductDatabaseSpreadsheet = 4 + KnowledgeTypeCustom = 5 +) + +// FileCreateService is a service to create a file. +type FileCreateService struct { + client *Client + + purpose string + + localFile string + file io.Reader + filename string + + customSeparator *string + sentenceSize *int + knowledgeID *string +} + +// FileCreateKnowledgeSuccessInfo is the success info of the FileCreateKnowledgeResponse. +type FileCreateKnowledgeSuccessInfo struct { + Filename string `json:"fileName"` + DocumentID string `json:"documentId"` +} + +// FileCreateKnowledgeFailedInfo is the failed info of the FileCreateKnowledgeResponse. +type FileCreateKnowledgeFailedInfo struct { + Filename string `json:"fileName"` + FailReason string `json:"failReason"` +} + +// FileCreateKnowledgeResponse is the response of the FileCreateService. +type FileCreateKnowledgeResponse struct { + SuccessInfos []FileCreateKnowledgeSuccessInfo `json:"successInfos"` + FailedInfos []FileCreateKnowledgeFailedInfo `json:"failedInfos"` +} + +// FileCreateFineTuneResponse is the response of the FileCreateService. +type FileCreateFineTuneResponse struct { + Bytes int64 `json:"bytes"` + CreatedAt int64 `json:"created_at"` + Filename string `json:"filename"` + Object string `json:"object"` + Purpose string `json:"purpose"` + ID string `json:"id"` +} + +// FileCreateResponse is the response of the FileCreateService. +type FileCreateResponse struct { + FileCreateFineTuneResponse + FileCreateKnowledgeResponse +} + +// NewFileCreateService creates a new FileCreateService. +func NewFileCreateService(client *Client) *FileCreateService { + return &FileCreateService{client: client} +} + +// SetLocalFile sets the local_file parameter of the FileCreateService. +func (s *FileCreateService) SetLocalFile(localFile string) *FileCreateService { + s.localFile = localFile + return s +} + +// SetFile sets the file parameter of the FileCreateService. +func (s *FileCreateService) SetFile(file io.Reader, filename string) *FileCreateService { + s.file = file + s.filename = filename + return s +} + +// SetPurpose sets the purpose parameter of the FileCreateService. +func (s *FileCreateService) SetPurpose(purpose string) *FileCreateService { + s.purpose = purpose + return s +} + +// SetCustomSeparator sets the custom_separator parameter of the FileCreateService. +func (s *FileCreateService) SetCustomSeparator(customSeparator string) *FileCreateService { + s.customSeparator = &customSeparator + return s +} + +// SetSentenceSize sets the sentence_size parameter of the FileCreateService. +func (s *FileCreateService) SetSentenceSize(sentenceSize int) *FileCreateService { + s.sentenceSize = &sentenceSize + return s +} + +// SetKnowledgeID sets the knowledge_id parameter of the FileCreateService. +func (s *FileCreateService) SetKnowledgeID(knowledgeID string) *FileCreateService { + s.knowledgeID = &knowledgeID + return s +} + +// Do makes the request. +func (s *FileCreateService) Do(ctx context.Context) (res FileCreateResponse, err error) { + var ( + resp *resty.Response + apiError APIErrorResponse + ) + + body := map[string]string{"purpose": s.purpose} + + if s.customSeparator != nil { + body["custom_separator"] = *s.customSeparator + } + if s.sentenceSize != nil { + body["sentence_size"] = strconv.Itoa(*s.sentenceSize) + } + if s.knowledgeID != nil { + body["knowledge_id"] = *s.knowledgeID + } + + file, filename := s.file, s.filename + + if file == nil && s.localFile != "" { + var f *os.File + if f, err = os.Open(s.localFile); err != nil { + return + } + defer f.Close() + + file = f + filename = filepath.Base(s.localFile) + } + + if file == nil { + err = errors.New("no file specified") + return + } + + if resp, err = s.client.request(ctx). + SetFileReader("file", filename, file). + SetMultipartFormData(body). + SetResult(&res). + SetError(&apiError). + Post("files"); err != nil { + return + } + + if resp.IsError() { + err = apiError + return + } + + return +} + +// FileEditService is a service to edit a file. +type FileEditService struct { + client *Client + + documentID string + + knowledgeType *int + customSeparator []string + sentenceSize *int +} + +// NewFileEditService creates a new FileEditService. +func NewFileEditService(client *Client) *FileEditService { + return &FileEditService{client: client} +} + +// SetDocumentID sets the document_id parameter of the FileEditService. +func (s *FileEditService) SetDocumentID(documentID string) *FileEditService { + s.documentID = documentID + return s +} + +// SetKnowledgeType sets the knowledge_type parameter of the FileEditService. +func (s *FileEditService) SetKnowledgeType(knowledgeType int) *FileEditService { + s.knowledgeType = &knowledgeType + return s +} + +// SetSentenceSize sets the sentence_size parameter of the FileEditService. +func (s *FileEditService) SetCustomSeparator(customSeparator ...string) *FileEditService { + s.customSeparator = customSeparator + return s +} + +// SetSentenceSize sets the sentence_size parameter of the FileEditService. +func (s *FileEditService) SetSentenceSize(sentenceSize int) *FileEditService { + s.sentenceSize = &sentenceSize + return s +} + +// Do makes the request. +func (s *FileEditService) Do(ctx context.Context) (err error) { + var ( + resp *resty.Response + apiError APIErrorResponse + ) + + body := M{} + + if s.knowledgeType != nil { + body["knowledge_type"] = strconv.Itoa(*s.knowledgeType) + } + if len(s.customSeparator) > 0 { + body["custom_separator"] = s.customSeparator + } + if s.sentenceSize != nil { + body["sentence_size"] = strconv.Itoa(*s.sentenceSize) + } + + if resp, err = s.client.request(ctx). + SetPathParam("document_id", s.documentID). + SetBody(body). + SetError(&apiError). + Put("document/{document_id}"); err != nil { + return + } + + if resp.IsError() { + err = apiError + return + } + + return +} + +// FileListService is a service to list files. +type FileListService struct { + client *Client + + purpose string + + knowledgeID *string + page *int + limit *int + after *string + orderAsc *bool +} + +// FileFailInfo is the failed info of the FileListKnowledgeItem. +type FileFailInfo struct { + EmbeddingCode int `json:"embedding_code"` + EmbeddingMsg string `json:"embedding_msg"` +} + +// FileListKnowledgeItem is the item of the FileListKnowledgeResponse. +type FileListKnowledgeItem struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Length int64 `json:"length"` + SentenceSize int64 `json:"sentence_size"` + CustomSeparator []string `json:"custom_separator"` + EmbeddingStat int `json:"embedding_stat"` + FailInfo *FileFailInfo `json:"failInfo"` + WordNum int64 `json:"word_num"` + ParseImage int `json:"parse_image"` +} + +// FileListKnowledgeResponse is the response of the FileListService. +type FileListKnowledgeResponse struct { + Total int `json:"total"` + List []FileListKnowledgeItem `json:"list"` +} + +// FileListFineTuneItem is the item of the FileListFineTuneResponse. +type FileListFineTuneItem struct { + Bytes int64 `json:"bytes"` + CreatedAt int64 `json:"created_at"` + Filename string `json:"filename"` + ID string `json:"id"` + Object string `json:"object"` + Purpose string `json:"purpose"` +} + +// FileListFineTuneResponse is the response of the FileListService. +type FileListFineTuneResponse struct { + Object string `json:"object"` + Data []FileListFineTuneItem `json:"data"` +} + +// FileListResponse is the response of the FileListService. +type FileListResponse struct { + FileListKnowledgeResponse + FileListFineTuneResponse +} + +// NewFileListService creates a new FileListService. +func NewFileListService(client *Client) *FileListService { + return &FileListService{client: client} +} + +// SetPurpose sets the purpose parameter of the FileListService. +func (s *FileListService) SetPurpose(purpose string) *FileListService { + s.purpose = purpose + return s +} + +// SetKnowledgeID sets the knowledge_id parameter of the FileListService. +func (s *FileListService) SetKnowledgeID(knowledgeID string) *FileListService { + s.knowledgeID = &knowledgeID + return s +} + +// SetPage sets the page parameter of the FileListService. +func (s *FileListService) SetPage(page int) *FileListService { + s.page = &page + return s +} + +// SetLimit sets the limit parameter of the FileListService. +func (s *FileListService) SetLimit(limit int) *FileListService { + s.limit = &limit + return s +} + +// SetAfter sets the after parameter of the FileListService. +func (s *FileListService) SetAfter(after string) *FileListService { + s.after = &after + return s +} + +// SetOrder sets the order parameter of the FileListService. +func (s *FileListService) SetOrder(asc bool) *FileListService { + s.orderAsc = &asc + return s +} + +// Do makes the request. +func (s *FileListService) Do(ctx context.Context) (res FileListResponse, err error) { + var ( + resp *resty.Response + apiError APIErrorResponse + ) + + m := map[string]string{ + "purpose": s.purpose, + } + + if s.knowledgeID != nil { + m["knowledge_id"] = *s.knowledgeID + } + if s.page != nil { + m["page"] = strconv.Itoa(*s.page) + } + if s.limit != nil { + m["limit"] = strconv.Itoa(*s.limit) + } + if s.after != nil { + m["after"] = *s.after + } + if s.orderAsc != nil { + if *s.orderAsc { + m["order"] = "asc" + } else { + m["order"] = "desc" + } + } + + if resp, err = s.client.request(ctx). + SetQueryParams(m). + SetResult(&res). + SetError(&apiError). + Get("files"); err != nil { + return + } + + if resp.IsError() { + err = apiError + return + } + + return +} + +// FileDeleteService is a service to delete a file. +type FileDeleteService struct { + client *Client + documentID string +} + +// NewFileDeleteService creates a new FileDeleteService. +func NewFileDeleteService(client *Client) *FileDeleteService { + return &FileDeleteService{client: client} +} + +// SetDocumentID sets the document_id parameter of the FileDeleteService. +func (s *FileDeleteService) SetDocumentID(documentID string) *FileDeleteService { + s.documentID = documentID + return s +} + +// Do makes the request. +func (s *FileDeleteService) Do(ctx context.Context) (err error) { + var ( + resp *resty.Response + apiError APIErrorResponse + ) + + if resp, err = s.client.request(ctx). + SetPathParam("document_id", s.documentID). + SetError(&apiError). + Delete("document/{document_id}"); err != nil { + return + } + + if resp.IsError() { + err = apiError + return + } + + return +} + +// FileGetService is a service to get a file. +type FileGetService struct { + client *Client + documentID string +} + +// FileGetResponse is the response of the FileGetService. +type FileGetResponse = FileListKnowledgeItem + +// NewFileGetService creates a new FileGetService. +func NewFileGetService(client *Client) *FileGetService { + return &FileGetService{client: client} +} + +// SetDocumentID sets the document_id parameter of the FileGetService. +func (s *FileGetService) SetDocumentID(documentID string) *FileGetService { + s.documentID = documentID + return s +} + +// Do makes the request. +func (s *FileGetService) Do(ctx context.Context) (res FileGetResponse, err error) { + var ( + resp *resty.Response + apiError APIErrorResponse + ) + + if resp, err = s.client.request(ctx). + SetPathParam("document_id", s.documentID). + SetResult(&res). + SetError(&apiError). + Get("document/{document_id}"); err != nil { + return + } + + if resp.IsError() { + err = apiError + return + } + + return +} + +// FileDownloadService is a service to download a file. +type FileDownloadService struct { + client *Client + + fileID string + + writer io.Writer + filename string +} + +// NewFileDownloadService creates a new FileDownloadService. +func NewFileDownloadService(client *Client) *FileDownloadService { + return &FileDownloadService{client: client} +} + +// SetFileID sets the file_id parameter of the FileDownloadService. +func (s *FileDownloadService) SetFileID(fileID string) *FileDownloadService { + s.fileID = fileID + return s +} + +// SetOutput sets the output parameter of the FileDownloadService. +func (s *FileDownloadService) SetOutput(w io.Writer) *FileDownloadService { + s.writer = w + return s +} + +// SetOutputFile sets the output_file parameter of the FileDownloadService. +func (s *FileDownloadService) SetOutputFile(filename string) *FileDownloadService { + s.filename = filename + return s +} + +// Do makes the request. +func (s *FileDownloadService) Do(ctx context.Context) (err error) { + var resp *resty.Response + + writer := s.writer + + if writer == nil && s.filename != "" { + var f *os.File + if f, err = os.Create(s.filename); err != nil { + return + } + defer f.Close() + + writer = f + } + + if writer == nil { + return errors.New("no output specified") + } + + if resp, err = s.client.request(ctx). + SetDoNotParseResponse(true). + SetPathParam("file_id", s.fileID). + Get("files/{file_id}/content"); err != nil { + return + } + defer resp.RawBody().Close() + + _, err = io.Copy(writer, resp.RawBody()) + + return +} diff --git a/zhipu/file_test.go b/zhipu/file_test.go new file mode 100644 index 0000000..3d035ae --- /dev/null +++ b/zhipu/file_test.go @@ -0,0 +1,71 @@ +package zhipu + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFileServiceFineTune(t *testing.T) { + client, err := NewClient() + require.NoError(t, err) + + s := client.FileCreate(FilePurposeFineTune) + s.SetLocalFile(filepath.Join("testdata", "test-file.jsonl")) + + res, err := s.Do(context.Background()) + require.NoError(t, err) + require.NotZero(t, res.Bytes) + require.NotZero(t, res.CreatedAt) + require.NotEmpty(t, res.ID) +} + +func TestFileServiceKnowledge(t *testing.T) { + client, err := NewClient() + require.NoError(t, err) + + s := client.FileCreate(FilePurposeRetrieval) + s.SetKnowledgeID(os.Getenv("TEST_KNOWLEDGE_ID")) + s.SetLocalFile(filepath.Join("testdata", "test-file.txt")) + + res, err := s.Do(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, res.SuccessInfos) + require.NotEmpty(t, res.SuccessInfos[0].DocumentID) + require.NotEmpty(t, res.SuccessInfos[0].Filename) + + documentID := res.SuccessInfos[0].DocumentID + + res2, err := client.FileGet(documentID).Do(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, res2.ID) + + err = client.FileEdit(documentID).SetKnowledgeType(KnowledgeTypeCustom).Do(context.Background()) + require.True(t, err == nil || GetAPIErrorCode(err) == "10019") + + err = client.FileDelete(res.SuccessInfos[0].DocumentID).Do(context.Background()) + require.True(t, err == nil || GetAPIErrorCode(err) == "10019") +} + +func TestFileListServiceKnowledge(t *testing.T) { + client, err := NewClient() + require.NoError(t, err) + + s := client.FileList(FilePurposeRetrieval).SetKnowledgeID(os.Getenv("TEST_KNOWLEDGE_ID")) + res, err := s.Do(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, res.List) +} + +func TestFileListServiceFineTune(t *testing.T) { + client, err := NewClient() + require.NoError(t, err) + + s := client.FileList(FilePurposeFineTune) + res, err := s.Do(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, res.Data) +} diff --git a/zhipu/fine_tune.go b/zhipu/fine_tune.go new file mode 100644 index 0000000..f2121ea --- /dev/null +++ b/zhipu/fine_tune.go @@ -0,0 +1,456 @@ +package zhipu + +import ( + "context" + "strconv" + + "github.com/go-resty/resty/v2" +) + +const ( + HyperParameterAuto = "auto" + + FineTuneStatusCreate = "create" + FineTuneStatusValidatingFiles = "validating_files" + FineTuneStatusQueued = "queued" + FineTuneStatusRunning = "running" + FineTuneStatusSucceeded = "succeeded" + FineTuneStatusFailed = "failed" + FineTuneStatusCancelled = "cancelled" +) + +// FineTuneItem is the item of the FineTune +type FineTuneItem struct { + ID string `json:"id"` + RequestID string `json:"request_id"` + FineTunedModel string `json:"fine_tuned_model"` + Status string `json:"status"` + Object string `json:"object"` + TrainingFile string `json:"training_file"` + ValidationFile string `json:"validation_file"` + Error APIError `json:"error"` +} + +// FineTuneCreateService creates a new fine tune +type FineTuneCreateService struct { + client *Client + + model string + trainingFile string + validationFile *string + + learningRateMultiplier *StringOr[float64] + batchSize *StringOr[int] + nEpochs *StringOr[int] + + suffix *string + requestID *string +} + +// FineTuneCreateResponse is the response of the FineTuneCreateService +type FineTuneCreateResponse = FineTuneItem + +// NewFineTuneCreateService creates a new FineTuneCreateService +func NewFineTuneCreateService(client *Client) *FineTuneCreateService { + return &FineTuneCreateService{ + client: client, + } +} + +// SetModel sets the model parameter +func (s *FineTuneCreateService) SetModel(model string) *FineTuneCreateService { + s.model = model + return s +} + +// SetTrainingFile sets the trainingFile parameter +func (s *FineTuneCreateService) SetTrainingFile(trainingFile string) *FineTuneCreateService { + s.trainingFile = trainingFile + return s +} + +// SetValidationFile sets the validationFile parameter +func (s *FineTuneCreateService) SetValidationFile(validationFile string) *FineTuneCreateService { + s.validationFile = &validationFile + return s +} + +// SetLearningRateMultiplier sets the learningRateMultiplier parameter +func (s *FineTuneCreateService) SetLearningRateMultiplier(learningRateMultiplier float64) *FineTuneCreateService { + s.learningRateMultiplier = &StringOr[float64]{} + s.learningRateMultiplier.SetValue(learningRateMultiplier) + return s +} + +// SetLearningRateMultiplierAuto sets the learningRateMultiplier parameter to auto +func (s *FineTuneCreateService) SetLearningRateMultiplierAuto() *FineTuneCreateService { + s.learningRateMultiplier = &StringOr[float64]{} + s.learningRateMultiplier.SetString(HyperParameterAuto) + return s +} + +// SetBatchSize sets the batchSize parameter +func (s *FineTuneCreateService) SetBatchSize(batchSize int) *FineTuneCreateService { + s.batchSize = &StringOr[int]{} + s.batchSize.SetValue(batchSize) + return s +} + +// SetBatchSizeAuto sets the batchSize parameter to auto +func (s *FineTuneCreateService) SetBatchSizeAuto() *FineTuneCreateService { + s.batchSize = &StringOr[int]{} + s.batchSize.SetString(HyperParameterAuto) + return s +} + +// SetNEpochs sets the nEpochs parameter +func (s *FineTuneCreateService) SetNEpochs(nEpochs int) *FineTuneCreateService { + s.nEpochs = &StringOr[int]{} + s.nEpochs.SetValue(nEpochs) + return s +} + +// SetNEpochsAuto sets the nEpochs parameter to auto +func (s *FineTuneCreateService) SetNEpochsAuto() *FineTuneCreateService { + s.nEpochs = &StringOr[int]{} + s.nEpochs.SetString(HyperParameterAuto) + return s +} + +// SetSuffix sets the suffix parameter +func (s *FineTuneCreateService) SetSuffix(suffix string) *FineTuneCreateService { + s.suffix = &suffix + return s +} + +// SetRequestID sets the requestID parameter +func (s *FineTuneCreateService) SetRequestID(requestID string) *FineTuneCreateService { + s.requestID = &requestID + return s +} + +// Do makes the request +func (s *FineTuneCreateService) Do(ctx context.Context) (res FineTuneCreateResponse, err error) { + var ( + resp *resty.Response + apiError APIErrorResponse + ) + + body := M{ + "model": s.model, + "training_file": s.trainingFile, + } + + if s.validationFile != nil { + body["validation_file"] = *s.validationFile + } + if s.suffix != nil { + body["suffix"] = *s.suffix + } + if s.requestID != nil { + body["request_id"] = *s.requestID + } + if s.learningRateMultiplier != nil || s.batchSize != nil || s.nEpochs != nil { + hp := M{} + if s.learningRateMultiplier != nil { + hp["learning_rate_multiplier"] = s.learningRateMultiplier + } + if s.batchSize != nil { + hp["batch_size"] = s.batchSize + } + if s.nEpochs != nil { + hp["n_epochs"] = s.nEpochs + } + body["hyperparameters"] = hp + } + + if resp, err = s.client.request(ctx). + SetBody(body). + SetResult(&res). + SetError(&apiError). + Post("fine_tuning/jobs"); err != nil { + return + } + if resp.IsError() { + err = apiError + return + } + return +} + +// FineTuneEventListService creates a new fine tune event list +type FineTuneEventListService struct { + client *Client + + jobID string + + limit *int + after *string +} + +// FineTuneEventData is the data of the FineTuneEventItem +type FineTuneEventData struct { + Acc float64 `json:"acc"` + Loss float64 `json:"loss"` + CurrentSteps int64 `json:"current_steps"` + RemainingTime string `json:"remaining_time"` + ElapsedTime string `json:"elapsed_time"` + TotalSteps int64 `json:"total_steps"` + Epoch int64 `json:"epoch"` + TrainedTokens int64 `json:"trained_tokens"` + LearningRate float64 `json:"learning_rate"` +} + +// FineTuneEventItem is the item of the FineTuneEventListResponse +type FineTuneEventItem struct { + ID string `json:"id"` + Type string `json:"type"` + Level string `json:"level"` + Message string `json:"message"` + Object string `json:"object"` + CreatedAt int64 `json:"created_at"` + Data FineTuneEventData `json:"data"` +} + +// FineTuneEventListResponse is the response of the FineTuneEventListService +type FineTuneEventListResponse struct { + Data []FineTuneEventItem `json:"data"` + HasMore bool `json:"has_more"` + Object string `json:"object"` +} + +// NewFineTuneEventListService creates a new FineTuneEventListService +func NewFineTuneEventListService(client *Client) *FineTuneEventListService { + return &FineTuneEventListService{ + client: client, + } +} + +// SetJobID sets the jobID parameter +func (s *FineTuneEventListService) SetJobID(jobID string) *FineTuneEventListService { + s.jobID = jobID + return s +} + +// SetLimit sets the limit parameter +func (s *FineTuneEventListService) SetLimit(limit int) *FineTuneEventListService { + s.limit = &limit + return s +} + +// SetAfter sets the after parameter +func (s *FineTuneEventListService) SetAfter(after string) *FineTuneEventListService { + s.after = &after + return s +} + +// Do makes the request +func (s *FineTuneEventListService) Do(ctx context.Context) (res FineTuneEventListResponse, err error) { + var ( + resp *resty.Response + apiError APIErrorResponse + ) + + req := s.client.request(ctx) + + if s.limit != nil { + req.SetQueryParam("limit", strconv.Itoa(*s.limit)) + } + if s.after != nil { + req.SetQueryParam("after", *s.after) + } + + if resp, err = req. + SetPathParam("job_id", s.jobID). + SetResult(&res). + SetError(&apiError). + Get("fine_tuning/jobs/{job_id}/events"); err != nil { + return + } + if resp.IsError() { + err = apiError + return + } + return +} + +// FineTuneGetService creates a new fine tune get +type FineTuneGetService struct { + client *Client + jobID string +} + +// NewFineTuneGetService creates a new FineTuneGetService +func NewFineTuneGetService(client *Client) *FineTuneGetService { + return &FineTuneGetService{ + client: client, + } +} + +// SetJobID sets the jobID parameter +func (s *FineTuneGetService) SetJobID(jobID string) *FineTuneGetService { + s.jobID = jobID + return s +} + +// Do makes the request +func (s *FineTuneGetService) Do(ctx context.Context) (res FineTuneItem, err error) { + var ( + resp *resty.Response + apiError APIErrorResponse + ) + + if resp, err = s.client.request(ctx). + SetPathParam("job_id", s.jobID). + SetResult(&res). + SetError(&apiError). + Get("fine_tuning/jobs/{job_id}"); err != nil { + return + } + if resp.IsError() { + err = apiError + return + } + return +} + +// FineTuneListService creates a new fine tune list +type FineTuneListService struct { + client *Client + + limit *int + after *string +} + +// FineTuneListResponse is the response of the FineTuneListService +type FineTuneListResponse struct { + Data []FineTuneItem `json:"data"` + Object string `json:"object"` +} + +// NewFineTuneListService creates a new FineTuneListService +func NewFineTuneListService(client *Client) *FineTuneListService { + return &FineTuneListService{ + client: client, + } +} + +// SetLimit sets the limit parameter +func (s *FineTuneListService) SetLimit(limit int) *FineTuneListService { + s.limit = &limit + return s +} + +// SetAfter sets the after parameter +func (s *FineTuneListService) SetAfter(after string) *FineTuneListService { + s.after = &after + return s +} + +// Do makes the request +func (s *FineTuneListService) Do(ctx context.Context) (res FineTuneListResponse, err error) { + var ( + resp *resty.Response + apiError APIErrorResponse + ) + + req := s.client.request(ctx) + if s.limit != nil { + req.SetQueryParam("limit", strconv.Itoa(*s.limit)) + } + if s.after != nil { + req.SetQueryParam("after", *s.after) + } + + if resp, err = req. + SetResult(&res). + SetError(&apiError). + Get("fine_tuning/jobs"); err != nil { + return + } + if resp.IsError() { + err = apiError + return + } + return +} + +// FineTuneDeleteService creates a new fine tune delete +type FineTuneDeleteService struct { + client *Client + jobID string +} + +// NewFineTuneDeleteService creates a new FineTuneDeleteService +func NewFineTuneDeleteService(client *Client) *FineTuneDeleteService { + return &FineTuneDeleteService{ + client: client, + } +} + +// SetJobID sets the jobID parameter +func (s *FineTuneDeleteService) SetJobID(jobID string) *FineTuneDeleteService { + s.jobID = jobID + return s +} + +// Do makes the request +func (s *FineTuneDeleteService) Do(ctx context.Context) (res FineTuneItem, err error) { + var ( + resp *resty.Response + apiError APIErrorResponse + ) + + if resp, err = s.client.request(ctx). + SetPathParam("job_id", s.jobID). + SetResult(&res). + SetError(&apiError). + Delete("fine_tuning/jobs/{job_id}"); err != nil { + return + } + if resp.IsError() { + err = apiError + return + } + return +} + +// FineTuneCancelService creates a new fine tune cancel +type FineTuneCancelService struct { + client *Client + jobID string +} + +// NewFineTuneCancelService creates a new FineTuneCancelService +func NewFineTuneCancelService(client *Client) *FineTuneCancelService { + return &FineTuneCancelService{ + client: client, + } +} + +// SetJobID sets the jobID parameter +func (s *FineTuneCancelService) SetJobID(jobID string) *FineTuneCancelService { + s.jobID = jobID + return s +} + +// Do makes the request +func (s *FineTuneCancelService) Do(ctx context.Context) (res FineTuneItem, err error) { + var ( + resp *resty.Response + apiError APIErrorResponse + ) + + if resp, err = s.client.request(ctx). + SetPathParam("job_id", s.jobID). + SetResult(&res). + SetError(&apiError). + Post("fine_tuning/jobs/{job_id}/cancel"); err != nil { + return + } + if resp.IsError() { + err = apiError + return + } + return +} diff --git a/zhipu/fine_tune_test.go b/zhipu/fine_tune_test.go new file mode 100644 index 0000000..2adb1fa --- /dev/null +++ b/zhipu/fine_tune_test.go @@ -0,0 +1,3 @@ +package zhipu + +// tests not available since lack of budget to test it diff --git a/zhipu/image_generation.go b/zhipu/image_generation.go new file mode 100644 index 0000000..32486a9 --- /dev/null +++ b/zhipu/image_generation.go @@ -0,0 +1,110 @@ +package zhipu + +import ( + "context" + + "github.com/go-resty/resty/v2" +) + +// ImageGenerationService creates a new image generation +type ImageGenerationService struct { + client *Client + + model string + prompt string + size string + userID string +} + +var ( + _ BatchSupport = &ImageGenerationService{} +) + +// ImageGenerationResponse is the response of the ImageGenerationService +type ImageGenerationResponse struct { + Created int64 `json:"created"` + Data []URLItem `json:"data"` +} + +// NewImageGenerationService creates a new ImageGenerationService +func NewImageGenerationService(client *Client) *ImageGenerationService { + return &ImageGenerationService{ + client: client, + } +} + +func (s *ImageGenerationService) BatchMethod() string { + return "POST" +} + +func (s *ImageGenerationService) BatchURL() string { + return BatchEndpointV4ImagesGenerations +} + +func (s *ImageGenerationService) BatchBody() any { + return s.buildBody() +} + +// SetModel sets the model parameter +func (s *ImageGenerationService) SetModel(model string) *ImageGenerationService { + s.model = model + return s +} + +// SetPrompt sets the prompt parameter +func (s *ImageGenerationService) SetPrompt(prompt string) *ImageGenerationService { + s.prompt = prompt + return s +} + +func (s *ImageGenerationService) SetSize(size string) *ImageGenerationService { + s.size = size + return s +} + +// SetUserID sets the userID parameter +func (s *ImageGenerationService) SetUserID(userID string) *ImageGenerationService { + s.userID = userID + return s +} + +func (s *ImageGenerationService) buildBody() M { + body := M{ + "model": s.model, + "prompt": s.prompt, + } + + if s.userID != "" { + body["user_id"] = s.userID + } + + if s.size != "" { + body["size"] = s.size + } + + return body +} + +func (s *ImageGenerationService) Do(ctx context.Context) (res ImageGenerationResponse, err error) { + var ( + resp *resty.Response + apiError APIErrorResponse + ) + + body := s.buildBody() + + if resp, err = s.client.request(ctx). + SetBody(body). + SetResult(&res). + SetError(&apiError). + Post("images/generations"); err != nil { + return + } + + if resp.IsError() { + err = apiError + return + } + + return +} diff --git a/zhipu/image_generation_test.go b/zhipu/image_generation_test.go new file mode 100644 index 0000000..7ab7807 --- /dev/null +++ b/zhipu/image_generation_test.go @@ -0,0 +1,21 @@ +package zhipu + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestImageGenerationService(t *testing.T) { + client, err := NewClient() + require.NoError(t, err) + + s := client.ImageGeneration("cogview-3") + s.SetPrompt("一只可爱的小猫") + + res, err := s.Do(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, res.Data) + t.Log(res.Data[0].URL) +} diff --git a/zhipu/knowledge.go b/zhipu/knowledge.go new file mode 100644 index 0000000..7f43808 --- /dev/null +++ b/zhipu/knowledge.go @@ -0,0 +1,299 @@ +package zhipu + +import ( + "context" + "strconv" + + "github.com/go-resty/resty/v2" +) + +const ( + KnowledgeEmbeddingIDEmbedding2 = 3 +) + +// KnowledgeCreateService creates a new knowledge +type KnowledgeCreateService struct { + client *Client + + embeddingID int + name string + description *string +} + +// KnowledgeCreateResponse is the response of the KnowledgeCreateService +type KnowledgeCreateResponse = IDItem + +// NewKnowledgeCreateService creates a new KnowledgeCreateService +func NewKnowledgeCreateService(client *Client) *KnowledgeCreateService { + return &KnowledgeCreateService{ + client: client, + } +} + +// SetEmbeddingID sets the embedding id of the knowledge +func (s *KnowledgeCreateService) SetEmbeddingID(embeddingID int) *KnowledgeCreateService { + s.embeddingID = embeddingID + return s +} + +// SetName sets the name of the knowledge +func (s *KnowledgeCreateService) SetName(name string) *KnowledgeCreateService { + s.name = name + return s +} + +// SetDescription sets the description of the knowledge +func (s *KnowledgeCreateService) SetDescription(description string) *KnowledgeCreateService { + s.description = &description + return s +} + +// Do creates the knowledge +func (s *KnowledgeCreateService) Do(ctx context.Context) (res KnowledgeCreateResponse, err error) { + var ( + resp *resty.Response + apiError APIErrorResponse + ) + body := M{ + "name": s.name, + "embedding_id": s.embeddingID, + } + if s.description != nil { + body["description"] = *s.description + } + if resp, err = s.client.request(ctx). + SetBody(body). + SetResult(&res). + SetError(&apiError). + Post("knowledge"); err != nil { + return + } + if resp.IsError() { + err = apiError + return + } + return +} + +// KnowledgeEditService edits a knowledge +type KnowledgeEditService struct { + client *Client + + knowledgeID string + + embeddingID *int + name *string + description *string +} + +// NewKnowledgeEditService creates a new KnowledgeEditService +func NewKnowledgeEditService(client *Client) *KnowledgeEditService { + return &KnowledgeEditService{ + client: client, + } +} + +// SetKnowledgeID sets the knowledge id +func (s *KnowledgeEditService) SetKnowledgeID(knowledgeID string) *KnowledgeEditService { + s.knowledgeID = knowledgeID + return s +} + +// SetName sets the name of the knowledge +func (s *KnowledgeEditService) SetName(name string) *KnowledgeEditService { + s.name = &name + return s +} + +// SetEmbeddingID sets the embedding id of the knowledge +func (s *KnowledgeEditService) SetEmbeddingID(embeddingID int) *KnowledgeEditService { + s.embeddingID = &embeddingID + return s +} + +// SetDescription sets the description of the knowledge +func (s *KnowledgeEditService) SetDescription(description string) *KnowledgeEditService { + s.description = &description + return s +} + +// Do edits the knowledge +func (s *KnowledgeEditService) Do(ctx context.Context) (err error) { + var ( + resp *resty.Response + apiError APIErrorResponse + ) + body := M{} + if s.name != nil { + body["name"] = *s.name + } + if s.description != nil { + body["description"] = *s.description + } + if s.embeddingID != nil { + body["embedding_id"] = *s.embeddingID + } + if resp, err = s.client.request(ctx). + SetPathParam("knowledge_id", s.knowledgeID). + SetBody(body). + SetError(&apiError). + Put("knowledge/{knowledge_id}"); err != nil { + return + } + if resp.IsError() { + err = apiError + return + } + return +} + +// KnowledgeListService lists the knowledge +type KnowledgeListService struct { + client *Client + + page *int + size *int +} + +// KnowledgeItem is an item in the knowledge list +type KnowledgeItem struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Icon string `json:"icon"` + Background string `json:"background"` + EmbeddingID int `json:"embedding_id"` + CustomIdentifier string `json:"custom_identifier"` + WordNum int64 `json:"word_num"` + Length int64 `json:"length"` + DocumentSize int64 `json:"document_size"` +} + +// KnowledgeListResponse is the response of the KnowledgeListService +type KnowledgeListResponse struct { + List []KnowledgeItem `json:"list"` + Total int `json:"total"` +} + +// NewKnowledgeListService creates a new KnowledgeListService +func NewKnowledgeListService(client *Client) *KnowledgeListService { + return &KnowledgeListService{client: client} +} + +// SetPage sets the page of the knowledge list +func (s *KnowledgeListService) SetPage(page int) *KnowledgeListService { + s.page = &page + return s +} + +// SetSize sets the size of the knowledge list +func (s *KnowledgeListService) SetSize(size int) *KnowledgeListService { + s.size = &size + return s +} + +// Do lists the knowledge +func (s *KnowledgeListService) Do(ctx context.Context) (res KnowledgeListResponse, err error) { + var ( + resp *resty.Response + apiError APIErrorResponse + ) + req := s.client.request(ctx) + if s.page != nil { + req.SetQueryParam("page", strconv.Itoa(*s.page)) + } + if s.size != nil { + req.SetQueryParam("size", strconv.Itoa(*s.size)) + } + if resp, err = req. + SetResult(&res). + SetError(&apiError). + Get("knowledge"); err != nil { + return + } + if resp.IsError() { + err = apiError + return + } + return +} + +// KnowledgeDeleteService deletes a knowledge +type KnowledgeDeleteService struct { + client *Client + + knowledgeID string +} + +// NewKnowledgeDeleteService creates a new KnowledgeDeleteService +func NewKnowledgeDeleteService(client *Client) *KnowledgeDeleteService { + return &KnowledgeDeleteService{ + client: client, + } +} + +// SetKnowledgeID sets the knowledge id +func (s *KnowledgeDeleteService) SetKnowledgeID(knowledgeID string) *KnowledgeDeleteService { + s.knowledgeID = knowledgeID + return s +} + +// Do deletes the knowledge +func (s *KnowledgeDeleteService) Do(ctx context.Context) (err error) { + var ( + resp *resty.Response + apiError APIErrorResponse + ) + if resp, err = s.client.request(ctx). + SetPathParam("knowledge_id", s.knowledgeID). + SetError(&apiError). + Delete("knowledge/{knowledge_id}"); err != nil { + return + } + if resp.IsError() { + err = apiError + return + } + return +} + +// KnowledgeCapacityService query the capacity of the knowledge +type KnowledgeCapacityService struct { + client *Client +} + +// KnowledgeCapacityItem is an item in the knowledge capacity +type KnowledgeCapacityItem struct { + WordNum int64 `json:"word_num"` + Length int64 `json:"length"` +} + +// KnowledgeCapacityResponse is the response of the KnowledgeCapacityService +type KnowledgeCapacityResponse struct { + Used KnowledgeCapacityItem `json:"used"` + Total KnowledgeCapacityItem `json:"total"` +} + +// SetKnowledgeID sets the knowledge id +func NewKnowledgeCapacityService(client *Client) *KnowledgeCapacityService { + return &KnowledgeCapacityService{client: client} +} + +// Do query the capacity of the knowledge +func (s *KnowledgeCapacityService) Do(ctx context.Context) (res KnowledgeCapacityResponse, err error) { + var ( + resp *resty.Response + apiError APIErrorResponse + ) + if resp, err = s.client.request(ctx). + SetResult(&res). + SetError(&apiError). + Get("knowledge/capacity"); err != nil { + return + } + if resp.IsError() { + err = apiError + return + } + return +} diff --git a/zhipu/knowledge_test.go b/zhipu/knowledge_test.go new file mode 100644 index 0000000..a330e74 --- /dev/null +++ b/zhipu/knowledge_test.go @@ -0,0 +1,50 @@ +package zhipu + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestKnowledgeCapacity(t *testing.T) { + client, err := NewClient() + require.NoError(t, err) + + s := client.KnowledgeCapacity() + res, err := s.Do(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, res.Total.Length) + require.NotEmpty(t, res.Total.WordNum) +} + +func TestKnowledgeServiceAll(t *testing.T) { + client, err := NewClient() + require.NoError(t, err) + + s := client.KnowledgeCreate() + s.SetName("test") + s.SetDescription("test description") + s.SetEmbeddingID(KnowledgeEmbeddingIDEmbedding2) + + res, err := s.Do(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, res.ID) + + s2 := client.KnowledgeList() + res2, err := s2.Do(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, res2.List) + require.Equal(t, res.ID, res2.List[0].ID) + + s3 := client.KnowledgeEdit(res.ID) + s3.SetDescription("test description 2") + s3.SetName("test 2") + s3.SetEmbeddingID(KnowledgeEmbeddingIDEmbedding2) + err = s3.Do(context.Background()) + require.NoError(t, err) + + s4 := client.KnowledgeDelete(res.ID) + err = s4.Do(context.Background()) + require.NoError(t, err) +} diff --git a/zhipu/string_or.go b/zhipu/string_or.go new file mode 100644 index 0000000..f0a72e6 --- /dev/null +++ b/zhipu/string_or.go @@ -0,0 +1,54 @@ +package zhipu + +import ( + "bytes" + "encoding/json" +) + +// StringOr is a struct that can be either a string or a value of type T. +type StringOr[T any] struct { + String *string + Value *T +} + +var ( + _ json.Marshaler = StringOr[float64]{} + _ json.Unmarshaler = &StringOr[float64]{} +) + +// SetString sets the string value of the struct. +func (f *StringOr[T]) SetString(v string) { + f.String = &v + f.Value = nil +} + +// SetValue sets the value of the struct. +func (f *StringOr[T]) SetValue(v T) { + f.String = nil + f.Value = &v +} + +func (f StringOr[T]) MarshalJSON() ([]byte, error) { + if f.Value != nil { + return json.Marshal(f.Value) + } + return json.Marshal(f.String) +} + +func (f *StringOr[T]) UnmarshalJSON(data []byte) error { + if len(data) == 0 { + return nil + } + if bytes.Equal(data, []byte("null")) { + return nil + } + if data[0] == '"' { + f.String = new(string) + f.Value = nil + return json.Unmarshal(data, f.String) + } else { + f.Value = new(T) + f.String = nil + return json.Unmarshal(data, f.Value) + } +} diff --git a/zhipu/string_or_test.go b/zhipu/string_or_test.go new file mode 100644 index 0000000..cdc27da --- /dev/null +++ b/zhipu/string_or_test.go @@ -0,0 +1,37 @@ +package zhipu + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestStringOr(t *testing.T) { + data := struct { + Item *StringOr[int] `json:"item,omitempty"` + }{} + data.Item = &StringOr[int]{} + data.Item.SetString("test") + + b, err := json.Marshal(data) + require.NoError(t, err) + require.Equal(t, `{"item":"test"}`, string(b)) + + data.Item.SetValue(1) + b, err = json.Marshal(data) + require.NoError(t, err) + require.Equal(t, `{"item":1}`, string(b)) + + err = json.Unmarshal([]byte(`{"item":"test2"}`), &data) + require.NoError(t, err) + require.NotNil(t, data.Item.String) + require.Nil(t, data.Item.Value) + require.Equal(t, "test2", *data.Item.String) + + err = json.Unmarshal([]byte(`{"item":2}`), &data) + require.NoError(t, err) + require.Nil(t, data.Item.String) + require.NotNil(t, data.Item.Value) + require.Equal(t, 2, *data.Item.Value) +} diff --git a/zhipu/testdata/test-file.jsonl b/zhipu/testdata/test-file.jsonl new file mode 100644 index 0000000..4737cfa --- /dev/null +++ b/zhipu/testdata/test-file.jsonl @@ -0,0 +1,3 @@ +{"messages": [{"role": "system", "content": "你是一位乐于助人,知识渊博的全能AI助手。"},{"role": "user", "content": "请介绍一下AlphaGo的基本原理。"}, {"role": "assistant", "content": "AlphaGo利用深度神经网络与树搜索算法相结合,通过对弈数据进行训练,逐步提升游戏水平,能够对围棋局面进行判断并选择最佳落子位置。"}]} +{"messages": [{"role": "system", "content": "你是一位乐于助人,知识渊博的全能AI助手。"},{"role": "user", "content": "请列举一些健康饮食的重要原则。"}, {"role": "assistant", "content": "健康饮食的重要原则包括:膳食应营养均衡,合理摄入各种营养素;多吃新鲜蔬果,适量饮水;少吃糖、油、盐,忌吃过量快餐;养成规律生活作息习惯,适量运动。"}]} +{"messages": [{"role": "system", "content": "你是一位乐于助人,知识渊博的全能AI助手。"},{"role": "user", "content": "请给出一些改善睡眠质量的建议。"}, {"role": "assistant", "content": "改善睡眠质量的建议包括:保证睡眠时间,避免睡眠不足;睡前放松身心,远离刺激;养成规律作息时间,不要频繁熬夜;适量运动,但不要运动过度;睡前可以喝一杯热牛奶等温和饮料。"}]} \ No newline at end of file diff --git a/zhipu/testdata/test-file.txt b/zhipu/testdata/test-file.txt new file mode 100644 index 0000000..ff3bb63 --- /dev/null +++ b/zhipu/testdata/test-file.txt @@ -0,0 +1 @@ +The quick brown fox jumps over the lazy dog \ No newline at end of file diff --git a/zhipu/util.go b/zhipu/util.go new file mode 100644 index 0000000..4912b68 --- /dev/null +++ b/zhipu/util.go @@ -0,0 +1,22 @@ +package zhipu + +// URLItem is a struct that contains a URL. +type URLItem struct { + URL string `json:"url,omitempty"` +} + +// IDItem is a struct that contains an ID. +type IDItem struct { + ID string `json:"id,omitempty"` +} + +// Ptr returns a pointer to the value passed in. +// Example: +// +// web_search_enable = zhipu.Ptr(false) +func Ptr[T any](v T) *T { + return &v +} + +// M is a shorthand for map[string]any. +type M = map[string]any diff --git a/zhipu/util_test.go b/zhipu/util_test.go new file mode 100644 index 0000000..52b5052 --- /dev/null +++ b/zhipu/util_test.go @@ -0,0 +1,3 @@ +package zhipu + +// nothing to test diff --git a/zhipu/video_generation.go b/zhipu/video_generation.go new file mode 100644 index 0000000..3ae3279 --- /dev/null +++ b/zhipu/video_generation.go @@ -0,0 +1,125 @@ +package zhipu + +import ( + "context" + + "github.com/go-resty/resty/v2" +) + +const ( + VideoGenerationTaskStatusProcessing = "PROCESSING" + VideoGenerationTaskStatusSuccess = "SUCCESS" + VideoGenerationTaskStatusFail = "FAIL" +) + +// VideoGenerationService creates a new video generation +type VideoGenerationService struct { + client *Client + + model string + prompt string + userID string + imageURL string + requestID string +} + +var ( + _ BatchSupport = &VideoGenerationService{} +) + +// VideoGenerationResponse is the response of the VideoGenerationService +type VideoGenerationResponse struct { + RequestID string `json:"request_id"` + ID string `json:"id"` + Model string `json:"model"` + TaskStatus string `json:"task_status"` +} + +func NewVideoGenerationService(client *Client) *VideoGenerationService { + return &VideoGenerationService{ + client: client, + } +} + +func (s *VideoGenerationService) BatchMethod() string { + return "POST" +} + +func (s *VideoGenerationService) BatchURL() string { + return BatchEndpointV4VideosGenerations +} + +func (s *VideoGenerationService) BatchBody() any { + return s.buildBody() +} + +// SetModel sets the model parameter +func (s *VideoGenerationService) SetModel(model string) *VideoGenerationService { + s.model = model + return s +} + +// SetPrompt sets the prompt parameter +func (s *VideoGenerationService) SetPrompt(prompt string) *VideoGenerationService { + s.prompt = prompt + return s +} + +// SetUserID sets the userID parameter +func (s *VideoGenerationService) SetUserID(userID string) *VideoGenerationService { + s.userID = userID + return s +} + +// SetImageURL sets the imageURL parameter +func (s *VideoGenerationService) SetImageURL(imageURL string) *VideoGenerationService { + s.imageURL = imageURL + return s +} + +// SetRequestID sets the requestID parameter +func (s *VideoGenerationService) SetRequestID(requestID string) *VideoGenerationService { + s.requestID = requestID + return s +} + +func (s *VideoGenerationService) buildBody() M { + body := M{ + "model": s.model, + "prompt": s.prompt, + } + if s.userID != "" { + body["user_id"] = s.userID + } + if s.imageURL != "" { + body["image_url"] = s.imageURL + } + if s.requestID != "" { + body["request_id"] = s.requestID + } + return body +} + +func (s *VideoGenerationService) Do(ctx context.Context) (res VideoGenerationResponse, err error) { + var ( + resp *resty.Response + apiError APIErrorResponse + ) + + body := s.buildBody() + + if resp, err = s.client.request(ctx). + SetBody(body). + SetResult(&res). + SetError(&apiError). + Post("videos/generations"); err != nil { + return + } + + if resp.IsError() { + err = apiError + return + } + + return +} diff --git a/zhipu/video_generation_test.go b/zhipu/video_generation_test.go new file mode 100644 index 0000000..8dc5ab4 --- /dev/null +++ b/zhipu/video_generation_test.go @@ -0,0 +1,38 @@ +package zhipu + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestVideoGeneration(t *testing.T) { + client, err := NewClient() + require.NoError(t, err) + + s := client.VideoGeneration("cogvideox") + s.SetPrompt("一只可爱的小猫") + + res, err := s.Do(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, res.TaskStatus) + require.NotEmpty(t, res.ID) + t.Log(res.ID) + + for { + res, err := client.AsyncResult(res.ID).Do(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, res.TaskStatus) + if res.TaskStatus == VideoGenerationTaskStatusSuccess { + require.NotEmpty(t, res.VideoResult) + t.Log(res.VideoResult[0].URL) + t.Log(res.VideoResult[0].CoverImageURL) + } + if res.TaskStatus != VideoGenerationTaskStatusProcessing { + break + } + time.Sleep(time.Second * 5) + } +} diff --git a/zhipu/wechat-donation.png b/zhipu/wechat-donation.png new file mode 100644 index 0000000000000000000000000000000000000000..f6519f67fa337cc63b861b4ef276698cff861e64 GIT binary patch literal 46799 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 z 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