@@ -2,29 +2,15 @@
2package hyper
2package hyper
10 "encoding/json"
7 "encoding/json"
14 "log/slog"
8 "log/slog"
24 "charm.land/catwalk/pkg/catwalk"
13 "charm.land/catwalk/pkg/catwalk"
26− "charm.land/fantasy/object"
27− "github.com/charmbracelet/crush/internal/event"
30//go:generate wget -O provider.json https://hyper.charm.land/api/v1/provider
16//go:generate wget -O provider.json https://hyper.charm.land/api/v1/provider
@@ -68,274 +54,3 @@ const (
68var BaseURL = sync.OnceValue(func() string {
54var BaseURL = sync.OnceValue(func() string {
69 return cmp.Or(os.Getenv("HYPER_URL"), defaultBaseURL)
55 return cmp.Or(os.Getenv("HYPER_URL"), defaultBaseURL)
73− ErrNoCredits = errors.New("you're out of credits")
74− ErrUnauthorized = errors.New("unauthorized")
81− headers map[string]string
85−// Option configures the proxy provider.
86−type Option = func(*options)
88−// New creates a new proxy provider.
89−func New(opts ...Option) (fantasy.Provider, error) {
91− baseURL: BaseURL() + "/api/v1/fantasy",
93− headers: map[string]string{
94− "x-crush-id": event.GetID(),
96− client: &http.Client{Timeout: 0}, // stream-safe
98− for _, opt := range opts {
101− return &provider{options: o}, nil
104−// WithBaseURL sets the proxy base URL (e.g. http://localhost:8080).
105−func WithBaseURL(url string) Option { return func(o *options) { o.baseURL = url } }
107−// WithName sets the provider name.
108−func WithName(name string) Option { return func(o *options) { o.name = name } }
110−// WithHeaders sets extra headers sent to the proxy.
111−func WithHeaders(headers map[string]string) Option {
112− return func(o *options) {
113− maps.Copy(o.headers, headers)
117−// WithHTTPClient sets custom HTTP client.
118−func WithHTTPClient(c *http.Client) Option { return func(o *options) { o.client = c } }
120−// WithAPIKey sets the API key.
121−func WithAPIKey(key string) Option {
122− return func(o *options) {
127−type provider struct{ options options }
129−func (p *provider) Name() string { return p.options.name }
131−// LanguageModel implements fantasy.Provider.
132−func (p *provider) LanguageModel(_ context.Context, modelID string) (fantasy.LanguageModel, error) {
134− return nil, errors.New("missing model id")
136− return &languageModel{modelID: modelID, provider: p.options.name, opts: p.options}, nil
139−type languageModel struct {
145−// GenerateObject implements fantasy.LanguageModel.
146−func (m *languageModel) GenerateObject(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
147− return object.GenerateWithTool(ctx, m, call)
150−// StreamObject implements fantasy.LanguageModel.
151−func (m *languageModel) StreamObject(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
152− return object.StreamWithTool(ctx, m, call)
155−func (m *languageModel) Provider() string { return m.provider }
156−func (m *languageModel) Model() string { return m.modelID }
158−// Generate implements fantasy.LanguageModel by calling the proxy JSON endpoint.
159−func (m *languageModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) {
160− resp, err := m.doRequest(ctx, false, call)
164− defer func() { _ = resp.Body.Close() }()
165− if resp.StatusCode < 200 || resp.StatusCode >= 300 {
166− b, _ := ioReadAllLimit(resp.Body, 64*1024)
167− return nil, fmt.Errorf("proxy generate error: %s", strings.TrimSpace(string(b)))
169− var out fantasy.Response
170− if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
176−// Stream implements fantasy.LanguageModel using SSE from the proxy.
177−func (m *languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
178− // Prefer explicit /stream endpoint
179− resp, err := m.doRequest(ctx, true, call)
183− switch resp.StatusCode {
184− case http.StatusTooManyRequests:
185− _ = resp.Body.Close()
186− return nil, toProviderError(resp, retryAfter(resp))
187− case http.StatusUnauthorized:
188− _ = resp.Body.Close()
189− return nil, ErrUnauthorized
190− case http.StatusPaymentRequired:
191− _ = resp.Body.Close()
192− return nil, ErrNoCredits
195− if resp.StatusCode < 200 || resp.StatusCode >= 300 {
196− defer func() { _ = resp.Body.Close() }()
197− b, _ := ioReadAllLimit(resp.Body, 64*1024)
198− return nil, &fantasy.ProviderError{
199− Title: "Stream Error",
200− Message: strings.TrimSpace(string(b)),
201− StatusCode: resp.StatusCode,
205− return func(yield func(fantasy.StreamPart) bool) {
206− defer func() { _ = resp.Body.Close() }()
207− scanner := bufio.NewScanner(resp.Body)
208− buf := make([]byte, 0, 64*1024)
209− scanner.Buffer(buf, 4*1024*1024)
213− dataBuf bytes.Buffer
215− dispatch = func() bool {
216− if dataBuf.Len() == 0 || event == "" {
221− var part fantasy.StreamPart
222− if err := json.Unmarshal(dataBuf.Bytes(), &part); err != nil {
223− return yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeError, Error: err})
225− if part.Type == fantasy.StreamPartTypeFinish {
235− for scanner.Scan() {
236− line := scanner.Text()
237− if line == "" { // event boundary
243− if strings.HasPrefix(line, ":") { // comment / ping
246− if strings.HasPrefix(line, "event: ") {
247− event = strings.TrimSpace(line[len("event: "):])
250− if strings.HasPrefix(line, "data: ") {
251− if dataBuf.Len() > 0 {
252− dataBuf.WriteByte('\n')
254− dataBuf.WriteString(line[len("data: "):])
258− if err := scanner.Err(); err != nil {
259− if sawFinish && (errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) {
260− // If we already saw an explicit finish event, treat cancellation as a no-op.
262− _ = yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeError, Error: err})
266− if err := ctx.Err(); err != nil && !sawFinish {
267− _ = yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeError, Error: err})
270− // flush any pending data
273− _ = yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeFinish})
278−func (m *languageModel) doRequest(ctx context.Context, stream bool, call fantasy.Call) (*http.Response, error) {
279− addr, err := url.Parse(m.opts.baseURL)
283− addr = addr.JoinPath(m.modelID)
285− addr = addr.JoinPath("stream")
287− addr = addr.JoinPath("generate")
290− body, err := json.Marshal(call)
295− req, err := http.NewRequestWithContext(ctx, http.MethodPost, addr.String(), bytes.NewReader(body))
299− req.Header.Set("Content-Type", "application/json")
301− req.Header.Set("Accept", "text/event-stream")
303− req.Header.Set("Accept", "application/json")
305− for k, v := range m.opts.headers {
306− req.Header.Set(k, v)
309− if m.opts.apiKey != "" {
310− req.Header.Set("Authorization", m.opts.apiKey)
312− return m.opts.client.Do(req)
315−// ioReadAllLimit reads up to n bytes.
316−func ioReadAllLimit(r io.Reader, n int64) ([]byte, error) {
321− lr := &io.LimitedReader{R: r, N: n}
322− _, err := b.ReadFrom(lr)
323− return b.Bytes(), err
326−func toProviderError(resp *http.Response, message string) error {
327− return &fantasy.ProviderError{
328− Title: fantasy.ErrorTitleForStatusCode(resp.StatusCode),
330− StatusCode: resp.StatusCode,
334−func retryAfter(resp *http.Response) string {
335− after, err := strconv.Atoi(resp.Header.Get("Retry-After"))
336− if err == nil && after > 0 {
337− d := time.Duration(after) * time.Second
338− return "Try again in " + d.String()
340− return "Try again later"